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