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