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