Skip to main content

skillfile_core/
parser.rs

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