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", "gitlab", "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
43fn flush_token(current: &mut String, parts: &mut Vec<String>) {
44    if !current.is_empty() {
45        parts.push(std::mem::take(current));
46    }
47}
48
49/// Split a manifest line respecting double-quoted fields.
50///
51/// Unquoted lines split identically to whitespace split.
52/// Double-quoted fields preserve internal spaces.
53fn split_line(line: &str) -> Vec<String> {
54    let mut parts = Vec::new();
55    let mut current = String::new();
56    let mut in_quotes = false;
57
58    for ch in line.chars() {
59        if ch == '"' {
60            in_quotes = !in_quotes;
61            continue;
62        }
63        if ch.is_whitespace() && !in_quotes {
64            flush_token(&mut current, &mut parts);
65            continue;
66        }
67        current.push(ch);
68    }
69    flush_token(&mut current, &mut parts);
70    parts
71}
72
73fn strip_inline_comment(parts: Vec<String>) -> Vec<String> {
74    if let Some(pos) = parts.iter().position(|p| p.starts_with('#')) {
75        parts[..pos].to_vec()
76    } else {
77        parts
78    }
79}
80
81/// Parse `owner/repo[@ref]` into `(owner_repo, ref_)`.
82///
83/// Supports:
84/// - `owner/repo` → `("owner/repo", None)`
85/// - `owner/repo@v4` → `("owner/repo", Some("v4"))`
86/// - `owner/repo@main` → `("owner/repo", Some("main"))`
87#[must_use]
88pub fn parse_owner_repo_ref(input: &str) -> (String, Option<String>) {
89    match input.split_once('@') {
90        Some((repo, ref_)) if !repo.is_empty() && !ref_.is_empty() => {
91            (repo.to_string(), Some(ref_.to_string()))
92        }
93        _ => (input.to_string(), None),
94    }
95}
96
97/// Resolve the effective ref from the `@`-syntax parsed ref and an explicit positional ref.
98///
99/// Priority: `owner/repo@ref` > explicit positional ref > `DEFAULT_REF`.
100#[must_use]
101pub fn resolve_explicit_owner_repo_ref(
102    at_ref: Option<String>,
103    positional_ref: Option<&str>,
104) -> Option<String> {
105    at_ref.or_else(|| positional_ref.map(String::from))
106}
107
108/// Resolve the effective ref from the `@`-syntax parsed ref and an explicit positional ref.
109///
110/// Priority: `owner/repo@ref` > explicit positional ref > `DEFAULT_REF`.
111#[must_use]
112pub fn resolve_owner_repo_ref(at_ref: Option<String>, positional_ref: Option<&str>) -> String {
113    resolve_explicit_owner_repo_ref(at_ref, positional_ref)
114        .unwrap_or_else(|| DEFAULT_REF.to_string())
115}
116
117fn parse_github_owner_repo(
118    raw_owner_repo: &str,
119    lineno: usize,
120    warnings: &mut Vec<String>,
121) -> Option<(String, Option<String>)> {
122    let (owner_repo, at_ref) = parse_owner_repo_ref(raw_owner_repo);
123    if owner_repo.contains('/') {
124        return Some((owner_repo, at_ref));
125    }
126    warnings.push(format!(
127        "warning: line {lineno}: invalid owner/repo '{raw_owner_repo}' \
128         — expected 'owner/repo' or 'owner/repo@ref' format"
129    ));
130    None
131}
132
133/// Parse a github entry line. parts[0]=source_type, parts[1]=entity_type, etc.
134fn parse_github_entry(
135    parts: &[String],
136    entity_type: EntityType,
137    lineno: usize,
138) -> (Option<Entry>, Vec<String>) {
139    let mut warnings = Vec::new();
140
141    // Detection: if parts[2] contains '/' → it's owner/repo (inferred name)
142    let (name, owner_repo, path_in_repo, ref_) = if parts[2].contains('/') {
143        if parts.len() < 4 {
144            warnings.push(format!(
145                "warning: line {lineno}: github entry needs at least: owner/repo path"
146            ));
147            return (None, warnings);
148        }
149        let Some((parsed_repo, parsed_ref)) =
150            parse_github_owner_repo(&parts[2], lineno, &mut warnings)
151        else {
152            return (None, warnings);
153        };
154        let ref_ = resolve_owner_repo_ref(parsed_ref, parts.get(4).map(String::as_str));
155        (infer_name(&parts[3]), parsed_repo, &parts[3], ref_)
156    } else {
157        if parts.len() < 5 {
158            warnings.push(format!(
159                "warning: line {lineno}: github entry needs at least: name owner/repo path"
160            ));
161            return (None, warnings);
162        }
163        let Some((parsed_repo, parsed_ref)) =
164            parse_github_owner_repo(&parts[3], lineno, &mut warnings)
165        else {
166            return (None, warnings);
167        };
168        let ref_ = resolve_owner_repo_ref(parsed_ref, parts.get(5).map(String::as_str));
169        (parts[2].clone(), parsed_repo, &parts[4], ref_)
170    };
171
172    let entry = Entry {
173        entity_type,
174        name,
175        source: SourceFields::Github {
176            owner_repo,
177            path_in_repo: path_in_repo.clone(),
178            ref_,
179        },
180    };
181    (Some(entry), warnings)
182}
183
184/// Parse a gitlab entry line. Identical logic to github but creates `SourceFields::Gitlab`.
185fn parse_gitlab_entry(
186    parts: &[String],
187    entity_type: EntityType,
188    lineno: usize,
189) -> (Option<Entry>, Vec<String>) {
190    let mut warnings = Vec::new();
191
192    let (name, owner_repo, path_in_repo, ref_) = if parts[2].contains('/') {
193        if parts.len() < 4 {
194            warnings.push(format!(
195                "warning: line {lineno}: gitlab entry needs at least: owner/repo path"
196            ));
197            return (None, warnings);
198        }
199        let ref_ = parts.get(4).map_or(DEFAULT_REF, String::as_str);
200        (infer_name(&parts[3]), &parts[2], &parts[3], ref_)
201    } else {
202        if parts.len() < 5 {
203            warnings.push(format!(
204                "warning: line {lineno}: gitlab entry needs at least: name owner/repo path"
205            ));
206            return (None, warnings);
207        }
208        if !parts[3].contains('/') {
209            warnings.push(format!(
210                "warning: line {lineno}: invalid owner/repo '{}' \
211                 — expected 'owner/repo' format",
212                parts[3],
213            ));
214            return (None, warnings);
215        }
216        let ref_ = parts.get(5).map_or(DEFAULT_REF, String::as_str);
217        (parts[2].clone(), &parts[3], &parts[4], ref_)
218    };
219
220    let entry = Entry {
221        entity_type,
222        name,
223        source: SourceFields::Gitlab {
224            owner_repo: owner_repo.clone(),
225            path_in_repo: path_in_repo.clone(),
226            ref_: ref_.to_owned(),
227        },
228    };
229    (Some(entry), warnings)
230}
231
232fn parse_local_entry(parts: &[String], entity_type: EntityType) -> (Option<Entry>, Vec<String>) {
233    let warnings = Vec::new();
234
235    // Detection: if parts[2] ends in ".md" or contains '/' → path (inferred name).
236    // With 4+ parts, parts[2] is always the explicit name and parts[3] the path.
237    // With exactly 3 parts, parts[2] is always the path (even bare directory names
238    // like "commit" that don't contain '/' or end in '.md').
239    let looks_like_path = Path::new(&parts[2])
240        .extension()
241        .is_some_and(|e| e.eq_ignore_ascii_case("md"))
242        || parts[2].contains('/');
243    if looks_like_path || parts.len() < 4 {
244        let local_path = &parts[2];
245        let name = infer_name(local_path);
246        (
247            Some(Entry {
248                entity_type,
249                name,
250                source: SourceFields::Local {
251                    path: local_path.clone(),
252                },
253            }),
254            warnings,
255        )
256    } else {
257        let name = &parts[2];
258        let local_path = &parts[3];
259        (
260            Some(Entry {
261                entity_type,
262                name: name.clone(),
263                source: SourceFields::Local {
264                    path: local_path.clone(),
265                },
266            }),
267            warnings,
268        )
269    }
270}
271
272fn parse_url_entry(
273    parts: &[String],
274    entity_type: EntityType,
275    lineno: usize,
276) -> (Option<Entry>, Vec<String>) {
277    let mut warnings = Vec::new();
278
279    // Detection: if parts[2] starts with "http" → URL (inferred name)
280    if parts[2].starts_with("http") {
281        let url = &parts[2];
282        let name = infer_name(url);
283        (
284            Some(Entry {
285                entity_type,
286                name,
287                source: SourceFields::Url { url: url.clone() },
288            }),
289            warnings,
290        )
291    } else {
292        if parts.len() < 4 {
293            warnings.push(format!("warning: line {lineno}: url entry needs: name url"));
294            return (None, warnings);
295        }
296        let name = &parts[2];
297        let url = &parts[3];
298        (
299            Some(Entry {
300                entity_type,
301                name: name.clone(),
302                source: SourceFields::Url { url: url.clone() },
303            }),
304            warnings,
305        )
306    }
307}
308
309struct ParseAccumulator {
310    entries: Vec<Entry>,
311    install_targets: Vec<InstallTarget>,
312    warnings: Vec<String>,
313    seen_names: HashSet<String>,
314}
315
316fn parse_install_line(parts: &[String], lineno: usize, acc: &mut ParseAccumulator) {
317    if parts.len() < 3 {
318        acc.warnings.push(format!(
319            "warning: line {lineno}: install line needs: adapter scope"
320        ));
321        return;
322    }
323    let scope_str = &parts[2];
324    if let Some(scope) = Scope::parse(scope_str) {
325        acc.install_targets.push(InstallTarget {
326            adapter: parts[1].clone(),
327            scope,
328        });
329    } else {
330        let valid: Vec<&str> = Scope::ALL
331            .iter()
332            .map(super::models::Scope::as_str)
333            .collect();
334        acc.warnings.push(format!(
335            "warning: line {lineno}: invalid scope '{scope_str}', \
336             must be one of: {}",
337            valid.join(", ")
338        ));
339    }
340}
341
342fn validate_and_push_entry(entry: Entry, lineno: usize, acc: &mut ParseAccumulator) {
343    if !is_valid_name(&entry.name) {
344        acc.warnings.push(format!(
345            "warning: line {lineno}: invalid name '{}' \
346             — names must match [a-zA-Z0-9._-], skipping",
347            entry.name
348        ));
349    } else if acc.seen_names.contains(&entry.name) {
350        acc.warnings.push(format!(
351            "warning: line {lineno}: duplicate entry name '{}'",
352            entry.name
353        ));
354        acc.entries.push(entry);
355    } else {
356        acc.seen_names.insert(entry.name.clone());
357        acc.entries.push(entry);
358    }
359}
360
361fn parse_source_entry(
362    parts: &[String],
363    lineno: usize,
364    source_type: &str,
365) -> (Option<Entry>, Vec<String>) {
366    if parts.len() < 3 {
367        return (
368            None,
369            vec![format!("warning: line {lineno}: too few fields, skipping")],
370        );
371    }
372    let Some(entity_type) = EntityType::parse(&parts[1]) else {
373        return (
374            None,
375            vec![format!(
376                "warning: line {lineno}: unknown entity type '{}', skipping",
377                parts[1]
378            )],
379        );
380    };
381    match source_type {
382        "github" => parse_github_entry(parts, entity_type, lineno),
383        "gitlab" => parse_gitlab_entry(parts, entity_type, lineno),
384        "local" => parse_local_entry(parts, entity_type),
385        "url" => parse_url_entry(parts, entity_type, lineno),
386        _ => (None, vec![]),
387    }
388}
389
390fn process_source_line(parts: &[String], lineno: usize, acc: &mut ParseAccumulator) {
391    let source_type = parts[0].as_str();
392    let (entry_opt, mut entry_warnings) = parse_source_entry(parts, lineno, source_type);
393    acc.warnings.append(&mut entry_warnings);
394    if let Some(entry) = entry_opt {
395        validate_and_push_entry(entry, lineno, acc);
396    }
397}
398
399pub fn parse_manifest(manifest_path: &Path) -> Result<ParseResult, SkillfileError> {
400    let raw_bytes = std::fs::read(manifest_path)?;
401
402    // Strip UTF-8 BOM if present
403    let text = if raw_bytes.starts_with(&[0xEF, 0xBB, 0xBF]) {
404        String::from_utf8_lossy(&raw_bytes[3..]).into_owned()
405    } else {
406        String::from_utf8_lossy(&raw_bytes).into_owned()
407    };
408
409    let mut acc = ParseAccumulator {
410        entries: Vec::new(),
411        install_targets: Vec::new(),
412        warnings: Vec::new(),
413        seen_names: HashSet::new(),
414    };
415
416    for (lineno, raw) in text.lines().enumerate() {
417        let lineno = lineno + 1; // 1-indexed
418        let line = raw.trim();
419        if line.is_empty() || line.starts_with('#') {
420            continue;
421        }
422
423        let parts = strip_inline_comment(split_line(line));
424        if parts.len() < 2 {
425            acc.warnings
426                .push(format!("warning: line {lineno}: too few fields, skipping"));
427            continue;
428        }
429
430        match parts[0].as_str() {
431            "install" => parse_install_line(&parts, lineno, &mut acc),
432            _ if KNOWN_SOURCES.contains(&parts[0].as_str()) => {
433                process_source_line(&parts, lineno, &mut acc);
434            }
435            st => {
436                acc.warnings.push(format!(
437                    "warning: line {lineno}: unknown source type '{st}', skipping"
438                ));
439            }
440        }
441    }
442
443    Ok(ParseResult {
444        manifest: Manifest {
445            entries: acc.entries,
446            install_targets: acc.install_targets,
447        },
448        warnings: acc.warnings,
449    })
450}
451
452#[must_use]
453pub fn parse_manifest_line(line: &str) -> Option<Entry> {
454    let parts = split_line(line);
455    let parts = strip_inline_comment(parts);
456    if parts.len() < 3 {
457        return None;
458    }
459    let source_type = parts[0].as_str();
460    if !KNOWN_SOURCES.contains(&source_type) || source_type == "install" {
461        return None;
462    }
463    let entity_type = EntityType::parse(&parts[1])?;
464    let (entry_opt, _) = match source_type {
465        "github" => parse_github_entry(&parts, entity_type, 0),
466        "gitlab" => parse_gitlab_entry(&parts, entity_type, 0),
467        "local" => parse_local_entry(&parts, entity_type),
468        "url" => parse_url_entry(&parts, entity_type, 0),
469        _ => return None,
470    };
471    entry_opt
472}
473
474pub fn find_entry_in<'a>(name: &str, manifest: &'a Manifest) -> Result<&'a Entry, SkillfileError> {
475    manifest
476        .entries
477        .iter()
478        .find(|e| e.name == name)
479        .ok_or_else(|| {
480            SkillfileError::Manifest(format!("no entry named '{name}' in {MANIFEST_NAME}"))
481        })
482}
483
484#[cfg(test)]
485mod tests {
486    use super::*;
487    use std::fs;
488
489    fn dedent_line(line: &str, indent: usize) -> &str {
490        if line.len() >= indent {
491            &line[indent..]
492        } else {
493            line.trim()
494        }
495    }
496
497    fn write_manifest(dir: &Path, content: &str) -> std::path::PathBuf {
498        let p = dir.join(MANIFEST_NAME);
499        // Dedent: strip leading whitespace common to all non-empty lines
500        let lines: Vec<&str> = content.lines().collect();
501        let min_indent = lines
502            .iter()
503            .filter(|l| !l.trim().is_empty())
504            .map(|l| l.len() - l.trim_start().len())
505            .min()
506            .unwrap_or(0);
507        let dedented: String = lines
508            .iter()
509            .map(|l| dedent_line(l, min_indent))
510            .collect::<Vec<_>>()
511            .join("\n");
512        fs::write(&p, dedented.trim_start_matches('\n').to_string() + "\n").unwrap();
513        p
514    }
515
516    // -------------------------------------------------------------------
517    // Existing entry types (explicit name + ref)
518    // -------------------------------------------------------------------
519
520    #[test]
521    fn github_entry_explicit_name_and_ref() {
522        let dir = tempfile::tempdir().unwrap();
523        let p = write_manifest(
524            dir.path(),
525            "github  agent  backend-dev  owner/repo  path/to/agent.md  main",
526        );
527        let r = parse_manifest(&p).unwrap();
528        assert_eq!(r.manifest.entries.len(), 1);
529        let e = &r.manifest.entries[0];
530        assert_eq!(e.source_type(), "github");
531        assert_eq!(e.entity_type, EntityType::Agent);
532        assert_eq!(e.name, "backend-dev");
533        assert_eq!(e.owner_repo(), "owner/repo");
534        assert_eq!(e.path_in_repo(), "path/to/agent.md");
535        assert_eq!(e.ref_(), "main");
536    }
537
538    #[test]
539    fn local_entry_bare_dir_name() {
540        let dir = tempfile::tempdir().unwrap();
541        let p = write_manifest(dir.path(), "local  skill  bash-craftsman");
542        let r = parse_manifest(&p).unwrap();
543        assert!(
544            r.warnings.is_empty(),
545            "unexpected warnings: {:?}",
546            r.warnings
547        );
548        assert_eq!(r.manifest.entries.len(), 1);
549        let e = &r.manifest.entries[0];
550        assert_eq!(e.source_type(), "local");
551        assert_eq!(e.entity_type, EntityType::Skill);
552        assert_eq!(e.name, "bash-craftsman");
553        assert_eq!(e.local_path(), "bash-craftsman");
554    }
555
556    #[test]
557    fn local_entry_explicit_name() {
558        let dir = tempfile::tempdir().unwrap();
559        let p = write_manifest(dir.path(), "local  skill  git-commit  skills/git/commit.md");
560        let r = parse_manifest(&p).unwrap();
561        assert_eq!(r.manifest.entries.len(), 1);
562        let e = &r.manifest.entries[0];
563        assert_eq!(e.source_type(), "local");
564        assert_eq!(e.entity_type, EntityType::Skill);
565        assert_eq!(e.name, "git-commit");
566        assert_eq!(e.local_path(), "skills/git/commit.md");
567    }
568
569    #[test]
570    fn url_entry_explicit_name() {
571        let dir = tempfile::tempdir().unwrap();
572        let p = write_manifest(
573            dir.path(),
574            "url  skill  my-skill  https://example.com/skill.md",
575        );
576        let r = parse_manifest(&p).unwrap();
577        assert_eq!(r.manifest.entries.len(), 1);
578        let e = &r.manifest.entries[0];
579        assert_eq!(e.source_type(), "url");
580        assert_eq!(e.name, "my-skill");
581        assert_eq!(e.url(), "https://example.com/skill.md");
582    }
583
584    // -------------------------------------------------------------------
585    // Optional name inference
586    // -------------------------------------------------------------------
587
588    #[test]
589    fn github_entry_inferred_name() {
590        let dir = tempfile::tempdir().unwrap();
591        let p = write_manifest(
592            dir.path(),
593            "github  agent  owner/repo  path/to/agent.md  main",
594        );
595        let r = parse_manifest(&p).unwrap();
596        assert_eq!(r.manifest.entries.len(), 1);
597        let e = &r.manifest.entries[0];
598        assert_eq!(e.name, "agent");
599        assert_eq!(e.owner_repo(), "owner/repo");
600        assert_eq!(e.path_in_repo(), "path/to/agent.md");
601        assert_eq!(e.ref_(), "main");
602    }
603
604    #[test]
605    fn local_entry_inferred_name_from_path() {
606        let dir = tempfile::tempdir().unwrap();
607        let p = write_manifest(dir.path(), "local  skill  skills/git/commit.md");
608        let r = parse_manifest(&p).unwrap();
609        assert_eq!(r.manifest.entries.len(), 1);
610        let e = &r.manifest.entries[0];
611        assert_eq!(e.name, "commit");
612        assert_eq!(e.local_path(), "skills/git/commit.md");
613    }
614
615    #[test]
616    fn local_entry_inferred_name_from_md_extension() {
617        let dir = tempfile::tempdir().unwrap();
618        let p = write_manifest(dir.path(), "local  skill  commit.md");
619        let r = parse_manifest(&p).unwrap();
620        assert_eq!(r.manifest.entries.len(), 1);
621        assert_eq!(r.manifest.entries[0].name, "commit");
622    }
623
624    #[test]
625    fn url_entry_inferred_name() {
626        let dir = tempfile::tempdir().unwrap();
627        let p = write_manifest(dir.path(), "url  skill  https://example.com/my-skill.md");
628        let r = parse_manifest(&p).unwrap();
629        assert_eq!(r.manifest.entries.len(), 1);
630        let e = &r.manifest.entries[0];
631        assert_eq!(e.name, "my-skill");
632        assert_eq!(e.url(), "https://example.com/my-skill.md");
633    }
634
635    // -------------------------------------------------------------------
636    // Optional ref (defaults to main)
637    // -------------------------------------------------------------------
638
639    #[test]
640    fn github_entry_inferred_name_default_ref() {
641        let dir = tempfile::tempdir().unwrap();
642        let p = write_manifest(dir.path(), "github  agent  owner/repo  path/to/agent.md");
643        let r = parse_manifest(&p).unwrap();
644        assert_eq!(r.manifest.entries[0].ref_(), "main");
645    }
646
647    #[test]
648    fn github_entry_explicit_name_default_ref() {
649        let dir = tempfile::tempdir().unwrap();
650        let p = write_manifest(
651            dir.path(),
652            "github  agent  my-agent  owner/repo  path/to/agent.md",
653        );
654        let r = parse_manifest(&p).unwrap();
655        assert_eq!(r.manifest.entries[0].ref_(), "main");
656    }
657
658    // -------------------------------------------------------------------
659    // @-syntax ref (owner/repo@ref)
660    // -------------------------------------------------------------------
661
662    #[test]
663    fn github_entry_at_ref_inferred_name() {
664        let dir = tempfile::tempdir().unwrap();
665        let p = write_manifest(dir.path(), "github  skill  nuxt/ui@v4  path/to/SKILL.md");
666        let r = parse_manifest(&p).unwrap();
667        assert_eq!(r.manifest.entries.len(), 1);
668        let e = &r.manifest.entries[0];
669        assert_eq!(e.name, "SKILL");
670        assert_eq!(e.owner_repo(), "nuxt/ui");
671        assert_eq!(e.ref_(), "v4");
672    }
673
674    #[test]
675    fn github_entry_at_ref_explicit_name() {
676        let dir = tempfile::tempdir().unwrap();
677        let p = write_manifest(
678            dir.path(),
679            "github  skill  my-skill  nuxt/ui@v4  path/to/SKILL.md",
680        );
681        let r = parse_manifest(&p).unwrap();
682        assert_eq!(r.manifest.entries.len(), 1);
683        let e = &r.manifest.entries[0];
684        assert_eq!(e.name, "my-skill");
685        assert_eq!(e.owner_repo(), "nuxt/ui");
686        assert_eq!(e.ref_(), "v4");
687    }
688
689    #[test]
690    fn github_entry_at_ref_with_main() {
691        let dir = tempfile::tempdir().unwrap();
692        let p = write_manifest(
693            dir.path(),
694            "github  skill  owner/repo@main  path/to/SKILL.md",
695        );
696        let r = parse_manifest(&p).unwrap();
697        assert_eq!(r.manifest.entries[0].owner_repo(), "owner/repo");
698        assert_eq!(r.manifest.entries[0].ref_(), "main");
699    }
700
701    #[test]
702    fn github_entry_at_ref_with_sha() {
703        let dir = tempfile::tempdir().unwrap();
704        let p = write_manifest(
705            dir.path(),
706            "github  skill  owner/repo@abc123def456  path/to/SKILL.md",
707        );
708        let r = parse_manifest(&p).unwrap();
709        assert_eq!(r.manifest.entries[0].owner_repo(), "owner/repo");
710        assert_eq!(r.manifest.entries[0].ref_(), "abc123def456");
711    }
712
713    #[test]
714    fn github_entry_at_ref_takes_priority_over_positional() {
715        let dir = tempfile::tempdir().unwrap();
716        let p = write_manifest(
717            dir.path(),
718            "github  skill  nuxt/ui@v4  path/to/SKILL.md  v3",
719        );
720        let r = parse_manifest(&p).unwrap();
721        let e = &r.manifest.entries[0];
722        assert_eq!(e.owner_repo(), "nuxt/ui");
723        assert_eq!(e.ref_(), "v4");
724    }
725
726    #[test]
727    fn github_entry_at_ref_requires_owner_repo_before_ref_separator() {
728        let dir = tempfile::tempdir().unwrap();
729        let p = write_manifest(dir.path(), "github  skill  us@tal/repo  path/to/SKILL.md");
730        let r = parse_manifest(&p).unwrap();
731        assert!(r.manifest.entries.is_empty());
732        assert!(r
733            .warnings
734            .iter()
735            .any(|warning| warning.contains("invalid owner/repo 'us@tal/repo'")));
736    }
737
738    #[test]
739    fn github_entry_at_ref_requires_owner_repo_before_ref_separator_with_name() {
740        let dir = tempfile::tempdir().unwrap();
741        let p = write_manifest(
742            dir.path(),
743            "github  skill  my-skill  us@tal/repo  path/to/SKILL.md",
744        );
745        let r = parse_manifest(&p).unwrap();
746        assert!(r.manifest.entries.is_empty());
747        assert!(r
748            .warnings
749            .iter()
750            .any(|warning| warning.contains("invalid owner/repo 'us@tal/repo'")));
751    }
752
753    // -------------------------------------------------------------------
754    // Install targets
755    // -------------------------------------------------------------------
756
757    #[test]
758    fn install_target_parsed() {
759        let dir = tempfile::tempdir().unwrap();
760        let p = write_manifest(dir.path(), "install  claude-code  global");
761        let r = parse_manifest(&p).unwrap();
762        assert_eq!(r.manifest.install_targets.len(), 1);
763        let t = &r.manifest.install_targets[0];
764        assert_eq!(t.adapter, "claude-code");
765        assert_eq!(t.scope, Scope::Global);
766    }
767
768    #[test]
769    fn multiple_install_targets() {
770        let dir = tempfile::tempdir().unwrap();
771        let p = write_manifest(
772            dir.path(),
773            "install  claude-code  global\ninstall  claude-code  local",
774        );
775        let r = parse_manifest(&p).unwrap();
776        assert_eq!(r.manifest.install_targets.len(), 2);
777        assert_eq!(r.manifest.install_targets[0].scope, Scope::Global);
778        assert_eq!(r.manifest.install_targets[1].scope, Scope::Local);
779    }
780
781    #[test]
782    fn install_targets_not_in_entries() {
783        let dir = tempfile::tempdir().unwrap();
784        let p = write_manifest(
785            dir.path(),
786            "install  claude-code  global\ngithub  agent  owner/repo  path/to/agent.md",
787        );
788        let r = parse_manifest(&p).unwrap();
789        assert_eq!(r.manifest.entries.len(), 1);
790        assert_eq!(r.manifest.install_targets.len(), 1);
791    }
792
793    // -------------------------------------------------------------------
794    // Comments, blanks, errors
795    // -------------------------------------------------------------------
796
797    #[test]
798    fn comments_and_blanks_skipped() {
799        let dir = tempfile::tempdir().unwrap();
800        let p = write_manifest(
801            dir.path(),
802            "# this is a comment\n\n# another comment\nlocal  skill  foo  skills/foo.md",
803        );
804        let r = parse_manifest(&p).unwrap();
805        assert_eq!(r.manifest.entries.len(), 1);
806    }
807
808    #[test]
809    fn malformed_too_few_fields() {
810        let dir = tempfile::tempdir().unwrap();
811        let p = write_manifest(dir.path(), "github  agent");
812        let r = parse_manifest(&p).unwrap();
813        assert!(r.manifest.entries.is_empty());
814        assert!(r.warnings.iter().any(|w| w.contains("warning")));
815    }
816
817    #[test]
818    fn unknown_source_type_skipped() {
819        let dir = tempfile::tempdir().unwrap();
820        let p = write_manifest(dir.path(), "svn  skill  foo  some/path");
821        let r = parse_manifest(&p).unwrap();
822        assert!(r.manifest.entries.is_empty());
823        assert!(r.warnings.iter().any(|w| w.contains("warning")));
824        assert!(r.warnings.iter().any(|w| w.contains("svn")));
825    }
826
827    // -------------------------------------------------------------------
828    // Inline comments
829    // -------------------------------------------------------------------
830
831    #[test]
832    fn inline_comment_stripped() {
833        let dir = tempfile::tempdir().unwrap();
834        let p = write_manifest(
835            dir.path(),
836            "github  agent  owner/repo  agents/foo.md  # my note",
837        );
838        let r = parse_manifest(&p).unwrap();
839        assert_eq!(r.manifest.entries.len(), 1);
840        let e = &r.manifest.entries[0];
841        assert_eq!(e.ref_(), "main"); // not "#"
842        assert_eq!(e.name, "foo");
843    }
844
845    #[test]
846    fn inline_comment_on_install_line() {
847        let dir = tempfile::tempdir().unwrap();
848        let p = write_manifest(dir.path(), "install  claude-code  global  # primary target");
849        let r = parse_manifest(&p).unwrap();
850        assert_eq!(r.manifest.install_targets.len(), 1);
851        assert_eq!(r.manifest.install_targets[0].scope, Scope::Global);
852    }
853
854    #[test]
855    fn inline_comment_after_ref() {
856        let dir = tempfile::tempdir().unwrap();
857        let p = write_manifest(
858            dir.path(),
859            "github  agent  my-agent  owner/repo  agents/foo.md  v1.0  # pinned version",
860        );
861        let r = parse_manifest(&p).unwrap();
862        assert_eq!(r.manifest.entries[0].ref_(), "v1.0");
863    }
864
865    // -------------------------------------------------------------------
866    // Quoted fields
867    // -------------------------------------------------------------------
868
869    #[test]
870    fn quoted_path_with_spaces() {
871        let dir = tempfile::tempdir().unwrap();
872        let p = dir.path().join(MANIFEST_NAME);
873        fs::write(&p, "local  skill  my-skill  \"skills/my dir/foo.md\"\n").unwrap();
874        let r = parse_manifest(&p).unwrap();
875        assert_eq!(r.manifest.entries.len(), 1);
876        assert_eq!(r.manifest.entries[0].local_path(), "skills/my dir/foo.md");
877    }
878
879    #[test]
880    fn quoted_github_path() {
881        let dir = tempfile::tempdir().unwrap();
882        let p = dir.path().join(MANIFEST_NAME);
883        fs::write(
884            &p,
885            "github  skill  owner/repo  \"path with spaces/skill.md\"\n",
886        )
887        .unwrap();
888        let r = parse_manifest(&p).unwrap();
889        assert_eq!(r.manifest.entries.len(), 1);
890        assert_eq!(
891            r.manifest.entries[0].path_in_repo(),
892            "path with spaces/skill.md"
893        );
894    }
895
896    #[test]
897    fn mixed_quoted_and_unquoted() {
898        let dir = tempfile::tempdir().unwrap();
899        let p = dir.path().join(MANIFEST_NAME);
900        fs::write(
901            &p,
902            "github  agent  my-agent  owner/repo  \"agents/path with spaces/foo.md\"\n",
903        )
904        .unwrap();
905        let r = parse_manifest(&p).unwrap();
906        assert_eq!(r.manifest.entries.len(), 1);
907        assert_eq!(r.manifest.entries[0].name, "my-agent");
908        assert_eq!(
909            r.manifest.entries[0].path_in_repo(),
910            "agents/path with spaces/foo.md"
911        );
912    }
913
914    #[test]
915    fn unquoted_fields_parse_identically() {
916        let dir = tempfile::tempdir().unwrap();
917        let p = write_manifest(
918            dir.path(),
919            "github  agent  backend-dev  owner/repo  path/to/agent.md  main",
920        );
921        let r = parse_manifest(&p).unwrap();
922        assert_eq!(r.manifest.entries[0].name, "backend-dev");
923        assert_eq!(r.manifest.entries[0].ref_(), "main");
924    }
925
926    // -------------------------------------------------------------------
927    // Name validation
928    // -------------------------------------------------------------------
929
930    #[test]
931    fn valid_entry_name_accepted() {
932        let dir = tempfile::tempdir().unwrap();
933        let p = write_manifest(dir.path(), "local  skill  my-skill_v2.0  skills/foo.md");
934        let r = parse_manifest(&p).unwrap();
935        assert_eq!(r.manifest.entries.len(), 1);
936        assert_eq!(r.manifest.entries[0].name, "my-skill_v2.0");
937    }
938
939    #[test]
940    fn invalid_entry_name_rejected() {
941        let dir = tempfile::tempdir().unwrap();
942        let p = dir.path().join(MANIFEST_NAME);
943        fs::write(&p, "local  skill  \"my skill!\"  skills/foo.md\n").unwrap();
944        let r = parse_manifest(&p).unwrap();
945        assert!(r.manifest.entries.is_empty());
946        assert!(r
947            .warnings
948            .iter()
949            .any(|w| w.to_lowercase().contains("invalid name")
950                || w.to_lowercase().contains("warning")));
951    }
952
953    #[test]
954    fn inferred_name_validated() {
955        let dir = tempfile::tempdir().unwrap();
956        let p = write_manifest(dir.path(), "local  skill  skills/foo.md");
957        let r = parse_manifest(&p).unwrap();
958        assert_eq!(r.manifest.entries.len(), 1);
959        assert_eq!(r.manifest.entries[0].name, "foo");
960    }
961
962    // -------------------------------------------------------------------
963    // Scope validation
964    // -------------------------------------------------------------------
965
966    #[test]
967    fn valid_scope_accepted() {
968        for (scope_str, expected) in &[("global", Scope::Global), ("local", Scope::Local)] {
969            let dir = tempfile::tempdir().unwrap();
970            let p = write_manifest(dir.path(), &format!("install  claude-code  {scope_str}"));
971            let r = parse_manifest(&p).unwrap();
972            assert_eq!(r.manifest.install_targets.len(), 1);
973            assert_eq!(r.manifest.install_targets[0].scope, *expected);
974        }
975    }
976
977    #[test]
978    fn invalid_scope_rejected() {
979        let dir = tempfile::tempdir().unwrap();
980        let p = write_manifest(dir.path(), "install  claude-code  worldwide");
981        let r = parse_manifest(&p).unwrap();
982        assert!(r.manifest.install_targets.is_empty());
983        assert!(r
984            .warnings
985            .iter()
986            .any(|w| w.to_lowercase().contains("scope") || w.to_lowercase().contains("warning")));
987    }
988
989    // -------------------------------------------------------------------
990    // Duplicate entry name warning
991    // -------------------------------------------------------------------
992
993    #[test]
994    fn duplicate_entry_name_warns() {
995        let dir = tempfile::tempdir().unwrap();
996        let p = write_manifest(
997            dir.path(),
998            "local  skill  foo  skills/foo.md\nlocal  agent  foo  agents/foo.md",
999        );
1000        let r = parse_manifest(&p).unwrap();
1001        assert_eq!(r.manifest.entries.len(), 2); // both included
1002        assert!(r
1003            .warnings
1004            .iter()
1005            .any(|w| w.to_lowercase().contains("duplicate")));
1006    }
1007
1008    // -------------------------------------------------------------------
1009    // UTF-8 BOM handling
1010    // -------------------------------------------------------------------
1011
1012    #[test]
1013    fn utf8_bom_handled() {
1014        let dir = tempfile::tempdir().unwrap();
1015        let p = dir.path().join(MANIFEST_NAME);
1016        let mut content = vec![0xEF, 0xBB, 0xBF]; // BOM
1017        content.extend_from_slice(b"install  claude-code  global\n");
1018        fs::write(&p, content).unwrap();
1019        let r = parse_manifest(&p).unwrap();
1020        assert_eq!(r.manifest.install_targets.len(), 1);
1021        assert_eq!(
1022            r.manifest.install_targets[0],
1023            InstallTarget {
1024                adapter: "claude-code".into(),
1025                scope: Scope::Global,
1026            }
1027        );
1028    }
1029
1030    // -------------------------------------------------------------------
1031    // Unknown entity type warning
1032    // -------------------------------------------------------------------
1033
1034    #[test]
1035    fn unknown_entity_type_skipped_with_warning() {
1036        let dir = tempfile::tempdir().unwrap();
1037        let p = write_manifest(dir.path(), "local  hook  foo  hooks/foo.md");
1038        let r = parse_manifest(&p).unwrap();
1039        assert!(r.manifest.entries.is_empty());
1040        assert!(r.warnings.iter().any(|w| w.contains("unknown entity type")));
1041    }
1042
1043    #[test]
1044    fn github_invalid_owner_repo_skipped_with_warning() {
1045        let dir = tempfile::tempdir().unwrap();
1046        // Explicit-name form: name is "my-skill", owner_repo is "noslash"
1047        let p = write_manifest(dir.path(), "github  skill  my-skill  noslash  path.md");
1048        let r = parse_manifest(&p).unwrap();
1049        assert!(
1050            r.manifest.entries.is_empty(),
1051            "entry with invalid owner/repo should be skipped"
1052        );
1053        assert!(r.warnings.iter().any(|w| w.contains("owner/repo")));
1054    }
1055
1056    #[test]
1057    fn github_invalid_owner_repo_after_lossy_utf8_decode_skipped_with_warning() {
1058        let dir = tempfile::tempdir().unwrap();
1059        let p = dir.path().join(MANIFEST_NAME);
1060        fs::write(
1061            &p,
1062            [
1063                240, 174, 174, 174, 240, 174, 174, 170, 240, 105, 116, 104, 117, 97, 10, 103, 105,
1064                116, 104, 117, 98, 12, 97, 103, 101, 110, 116, 12, 117, 115, 64, 116, 97, 108, 170,
1065                170, 115, 47, 108, 1, 57, 12, 108, 12, 59, 239, 191, 10,
1066            ],
1067        )
1068        .unwrap();
1069        let r = parse_manifest(&p).unwrap();
1070        assert!(r.manifest.entries.is_empty());
1071        assert!(r.warnings.iter().any(|w| w.contains("invalid owner/repo")));
1072    }
1073
1074    // -------------------------------------------------------------------
1075    // find_entry_in
1076    // -------------------------------------------------------------------
1077
1078    #[test]
1079    fn find_entry_in_found() {
1080        let e = Entry {
1081            entity_type: EntityType::Skill,
1082            name: "foo".into(),
1083            source: SourceFields::Local {
1084                path: "foo.md".into(),
1085            },
1086        };
1087        let m = Manifest {
1088            entries: vec![e.clone()],
1089            install_targets: vec![],
1090        };
1091        assert_eq!(find_entry_in("foo", &m).unwrap(), &e);
1092    }
1093
1094    #[test]
1095    fn find_entry_in_not_found() {
1096        let m = Manifest::default();
1097        assert!(find_entry_in("missing", &m).is_err());
1098    }
1099
1100    // -------------------------------------------------------------------
1101    // infer_name
1102    // -------------------------------------------------------------------
1103
1104    #[test]
1105    fn infer_name_from_md_path() {
1106        assert_eq!(infer_name("path/to/agent.md"), "agent");
1107    }
1108
1109    #[test]
1110    fn infer_name_from_dot() {
1111        assert_eq!(infer_name("."), "content");
1112    }
1113
1114    #[test]
1115    fn infer_name_from_url() {
1116        assert_eq!(infer_name("https://example.com/my-skill.md"), "my-skill");
1117    }
1118
1119    // -------------------------------------------------------------------
1120    // split_line
1121    // -------------------------------------------------------------------
1122
1123    #[test]
1124    fn split_line_simple() {
1125        assert_eq!(
1126            split_line("github  agent  owner/repo  agent.md"),
1127            vec!["github", "agent", "owner/repo", "agent.md"]
1128        );
1129    }
1130
1131    #[test]
1132    fn split_line_quoted() {
1133        assert_eq!(
1134            split_line("local  skill  \"my dir/foo.md\""),
1135            vec!["local", "skill", "my dir/foo.md"]
1136        );
1137    }
1138
1139    #[test]
1140    fn split_line_tabs() {
1141        assert_eq!(
1142            split_line("local\tskill\tfoo.md"),
1143            vec!["local", "skill", "foo.md"]
1144        );
1145    }
1146
1147    // -------------------------------------------------------------------
1148    // GitLab entries
1149    // -------------------------------------------------------------------
1150
1151    #[test]
1152    fn gitlab_entry_explicit_name_and_ref() {
1153        let dir = tempfile::tempdir().unwrap();
1154        let p = write_manifest(
1155            dir.path(),
1156            "gitlab  skill  my-skill  my-group/my-project  skills/my-skill.md  v2.0",
1157        );
1158        let r = parse_manifest(&p).unwrap();
1159        assert_eq!(r.manifest.entries.len(), 1);
1160        let e = &r.manifest.entries[0];
1161        assert_eq!(e.source_type(), "gitlab");
1162        assert_eq!(e.entity_type, EntityType::Skill);
1163        assert_eq!(e.name, "my-skill");
1164        let (or, pir, ref_) = e.source.as_gitlab().unwrap();
1165        assert_eq!(or, "my-group/my-project");
1166        assert_eq!(pir, "skills/my-skill.md");
1167        assert_eq!(ref_, "v2.0");
1168    }
1169
1170    #[test]
1171    fn gitlab_entry_inferred_name_default_ref() {
1172        let dir = tempfile::tempdir().unwrap();
1173        let p = write_manifest(
1174            dir.path(),
1175            "gitlab  agent  my-group/my-project  agents/reviewer.md",
1176        );
1177        let r = parse_manifest(&p).unwrap();
1178        assert_eq!(r.manifest.entries.len(), 1);
1179        let e = &r.manifest.entries[0];
1180        assert_eq!(e.source_type(), "gitlab");
1181        assert_eq!(e.name, "reviewer");
1182        let (or, pir, ref_) = e.source.as_gitlab().unwrap();
1183        assert_eq!(or, "my-group/my-project");
1184        assert_eq!(pir, "agents/reviewer.md");
1185        assert_eq!(ref_, "main");
1186    }
1187
1188    #[test]
1189    fn gitlab_entry_too_few_fields_warns() {
1190        let dir = tempfile::tempdir().unwrap();
1191        let p = write_manifest(dir.path(), "gitlab  skill");
1192        let r = parse_manifest(&p).unwrap();
1193        assert!(r.manifest.entries.is_empty());
1194        assert!(r.warnings.iter().any(|w| w.contains("warning")));
1195    }
1196}