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