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