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#[derive(Debug)]
12pub struct ParseResult {
13 pub manifest: Manifest,
14 pub warnings: Vec<String>,
15}
16
17#[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
35fn 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 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
71fn 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
80fn 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 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
146fn 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 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
190fn 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 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
228fn parse_install_line(
230 parts: &[String],
231 lineno: usize,
232 install_targets: &mut Vec<InstallTarget>,
233 warnings: &mut Vec<String>,
234) {
235 if parts.len() < 3 {
236 warnings.push(format!(
237 "warning: line {lineno}: install line needs: adapter scope"
238 ));
239 return;
240 }
241 let scope_str = &parts[2];
242 match Scope::parse(scope_str) {
243 Some(scope) => {
244 install_targets.push(InstallTarget {
245 adapter: parts[1].clone(),
246 scope,
247 });
248 }
249 None => {
250 let valid: Vec<&str> = Scope::ALL.iter().map(|s| s.as_str()).collect();
251 warnings.push(format!(
252 "warning: line {lineno}: invalid scope '{scope_str}', \
253 must be one of: {}",
254 valid.join(", ")
255 ));
256 }
257 }
258}
259
260fn validate_and_push_entry(
262 entry: Entry,
263 lineno: usize,
264 seen_names: &mut HashSet<String>,
265 entries: &mut Vec<Entry>,
266 warnings: &mut Vec<String>,
267) {
268 if !is_valid_name(&entry.name) {
269 warnings.push(format!(
270 "warning: line {lineno}: invalid name '{}' \
271 — names must match [a-zA-Z0-9._-], skipping",
272 entry.name
273 ));
274 } else if seen_names.contains(&entry.name) {
275 warnings.push(format!(
276 "warning: line {lineno}: duplicate entry name '{}'",
277 entry.name
278 ));
279 entries.push(entry);
280 } else {
281 seen_names.insert(entry.name.clone());
282 entries.push(entry);
283 }
284}
285
286pub fn parse_manifest(manifest_path: &Path) -> Result<ParseResult, SkillfileError> {
288 let raw_bytes = std::fs::read(manifest_path)?;
289
290 let text = if raw_bytes.starts_with(&[0xEF, 0xBB, 0xBF]) {
292 String::from_utf8_lossy(&raw_bytes[3..]).into_owned()
293 } else {
294 String::from_utf8_lossy(&raw_bytes).into_owned()
295 };
296
297 let mut entries = Vec::new();
298 let mut install_targets = Vec::new();
299 let mut warnings = Vec::new();
300 let mut seen_names = HashSet::new();
301
302 for (lineno, raw) in text.lines().enumerate() {
303 let lineno = lineno + 1; let line = raw.trim();
305 if line.is_empty() || line.starts_with('#') {
306 continue;
307 }
308
309 let parts = split_line(line);
310 let parts = strip_inline_comment(parts);
311 if parts.len() < 2 {
312 warnings.push(format!("warning: line {lineno}: too few fields, skipping"));
313 continue;
314 }
315
316 let source_type = &parts[0];
317
318 match source_type.as_str() {
319 "install" => {
320 parse_install_line(&parts, lineno, &mut install_targets, &mut warnings);
321 }
322 st if KNOWN_SOURCES.contains(&st) => {
323 if parts.len() < 3 {
324 warnings.push(format!("warning: line {lineno}: too few fields, skipping"));
325 continue;
326 }
327 let entity_type = match EntityType::parse(&parts[1]) {
328 Some(et) => et,
329 None => {
330 warnings.push(format!(
331 "warning: line {lineno}: unknown entity type '{}', skipping",
332 parts[1]
333 ));
334 continue;
335 }
336 };
337 let (entry_opt, mut entry_warnings) = match st {
338 "github" => parse_github_entry(&parts, entity_type, lineno),
339 "local" => parse_local_entry(&parts, entity_type, lineno),
340 "url" => parse_url_entry(&parts, entity_type, lineno),
341 _ => unreachable!(),
342 };
343 warnings.append(&mut entry_warnings);
344 if let Some(entry) = entry_opt {
345 validate_and_push_entry(
346 entry,
347 lineno,
348 &mut seen_names,
349 &mut entries,
350 &mut warnings,
351 );
352 }
353 }
354 _ => {
355 warnings.push(format!(
356 "warning: line {lineno}: unknown source type '{source_type}', skipping"
357 ));
358 }
359 }
360 }
361
362 Ok(ParseResult {
363 manifest: Manifest {
364 entries,
365 install_targets,
366 },
367 warnings,
368 })
369}
370
371#[must_use]
373pub fn parse_manifest_line(line: &str) -> Option<Entry> {
374 let parts = split_line(line);
375 let parts = strip_inline_comment(parts);
376 if parts.len() < 3 {
377 return None;
378 }
379 let source_type = parts[0].as_str();
380 if !KNOWN_SOURCES.contains(&source_type) || source_type == "install" {
381 return None;
382 }
383 let entity_type = EntityType::parse(&parts[1])?;
384 let (entry_opt, _) = match source_type {
385 "github" => parse_github_entry(&parts, entity_type, 0),
386 "local" => parse_local_entry(&parts, entity_type, 0),
387 "url" => parse_url_entry(&parts, entity_type, 0),
388 _ => return None,
389 };
390 entry_opt
391}
392
393pub fn find_entry_in<'a>(name: &str, manifest: &'a Manifest) -> Result<&'a Entry, SkillfileError> {
395 manifest
396 .entries
397 .iter()
398 .find(|e| e.name == name)
399 .ok_or_else(|| {
400 SkillfileError::Manifest(format!("no entry named '{name}' in {MANIFEST_NAME}"))
401 })
402}
403
404#[cfg(test)]
405mod tests {
406 use super::*;
407 use std::fs;
408
409 fn write_manifest(dir: &Path, content: &str) -> std::path::PathBuf {
410 let p = dir.join(MANIFEST_NAME);
411 let lines: Vec<&str> = content.lines().collect();
413 let min_indent = lines
414 .iter()
415 .filter(|l| !l.trim().is_empty())
416 .map(|l| l.len() - l.trim_start().len())
417 .min()
418 .unwrap_or(0);
419 let dedented: String = lines
420 .iter()
421 .map(|l| {
422 if l.len() >= min_indent {
423 &l[min_indent..]
424 } else {
425 l.trim()
426 }
427 })
428 .collect::<Vec<_>>()
429 .join("\n");
430 fs::write(&p, dedented.trim_start_matches('\n').to_string() + "\n").unwrap();
431 p
432 }
433
434 #[test]
439 fn github_entry_explicit_name_and_ref() {
440 let dir = tempfile::tempdir().unwrap();
441 let p = write_manifest(
442 dir.path(),
443 "github agent backend-dev owner/repo path/to/agent.md main",
444 );
445 let r = parse_manifest(&p).unwrap();
446 assert_eq!(r.manifest.entries.len(), 1);
447 let e = &r.manifest.entries[0];
448 assert_eq!(e.source_type(), "github");
449 assert_eq!(e.entity_type, EntityType::Agent);
450 assert_eq!(e.name, "backend-dev");
451 assert_eq!(e.owner_repo(), "owner/repo");
452 assert_eq!(e.path_in_repo(), "path/to/agent.md");
453 assert_eq!(e.ref_(), "main");
454 }
455
456 #[test]
457 fn local_entry_explicit_name() {
458 let dir = tempfile::tempdir().unwrap();
459 let p = write_manifest(dir.path(), "local skill git-commit skills/git/commit.md");
460 let r = parse_manifest(&p).unwrap();
461 assert_eq!(r.manifest.entries.len(), 1);
462 let e = &r.manifest.entries[0];
463 assert_eq!(e.source_type(), "local");
464 assert_eq!(e.entity_type, EntityType::Skill);
465 assert_eq!(e.name, "git-commit");
466 assert_eq!(e.local_path(), "skills/git/commit.md");
467 }
468
469 #[test]
470 fn url_entry_explicit_name() {
471 let dir = tempfile::tempdir().unwrap();
472 let p = write_manifest(
473 dir.path(),
474 "url skill my-skill https://example.com/skill.md",
475 );
476 let r = parse_manifest(&p).unwrap();
477 assert_eq!(r.manifest.entries.len(), 1);
478 let e = &r.manifest.entries[0];
479 assert_eq!(e.source_type(), "url");
480 assert_eq!(e.name, "my-skill");
481 assert_eq!(e.url(), "https://example.com/skill.md");
482 }
483
484 #[test]
489 fn github_entry_inferred_name() {
490 let dir = tempfile::tempdir().unwrap();
491 let p = write_manifest(
492 dir.path(),
493 "github agent owner/repo path/to/agent.md main",
494 );
495 let r = parse_manifest(&p).unwrap();
496 assert_eq!(r.manifest.entries.len(), 1);
497 let e = &r.manifest.entries[0];
498 assert_eq!(e.name, "agent");
499 assert_eq!(e.owner_repo(), "owner/repo");
500 assert_eq!(e.path_in_repo(), "path/to/agent.md");
501 assert_eq!(e.ref_(), "main");
502 }
503
504 #[test]
505 fn local_entry_inferred_name_from_path() {
506 let dir = tempfile::tempdir().unwrap();
507 let p = write_manifest(dir.path(), "local skill skills/git/commit.md");
508 let r = parse_manifest(&p).unwrap();
509 assert_eq!(r.manifest.entries.len(), 1);
510 let e = &r.manifest.entries[0];
511 assert_eq!(e.name, "commit");
512 assert_eq!(e.local_path(), "skills/git/commit.md");
513 }
514
515 #[test]
516 fn local_entry_inferred_name_from_md_extension() {
517 let dir = tempfile::tempdir().unwrap();
518 let p = write_manifest(dir.path(), "local skill commit.md");
519 let r = parse_manifest(&p).unwrap();
520 assert_eq!(r.manifest.entries.len(), 1);
521 assert_eq!(r.manifest.entries[0].name, "commit");
522 }
523
524 #[test]
525 fn url_entry_inferred_name() {
526 let dir = tempfile::tempdir().unwrap();
527 let p = write_manifest(dir.path(), "url skill https://example.com/my-skill.md");
528 let r = parse_manifest(&p).unwrap();
529 assert_eq!(r.manifest.entries.len(), 1);
530 let e = &r.manifest.entries[0];
531 assert_eq!(e.name, "my-skill");
532 assert_eq!(e.url(), "https://example.com/my-skill.md");
533 }
534
535 #[test]
540 fn github_entry_inferred_name_default_ref() {
541 let dir = tempfile::tempdir().unwrap();
542 let p = write_manifest(dir.path(), "github agent owner/repo path/to/agent.md");
543 let r = parse_manifest(&p).unwrap();
544 assert_eq!(r.manifest.entries[0].ref_(), "main");
545 }
546
547 #[test]
548 fn github_entry_explicit_name_default_ref() {
549 let dir = tempfile::tempdir().unwrap();
550 let p = write_manifest(
551 dir.path(),
552 "github agent my-agent owner/repo path/to/agent.md",
553 );
554 let r = parse_manifest(&p).unwrap();
555 assert_eq!(r.manifest.entries[0].ref_(), "main");
556 }
557
558 #[test]
563 fn install_target_parsed() {
564 let dir = tempfile::tempdir().unwrap();
565 let p = write_manifest(dir.path(), "install claude-code global");
566 let r = parse_manifest(&p).unwrap();
567 assert_eq!(r.manifest.install_targets.len(), 1);
568 let t = &r.manifest.install_targets[0];
569 assert_eq!(t.adapter, "claude-code");
570 assert_eq!(t.scope, Scope::Global);
571 }
572
573 #[test]
574 fn multiple_install_targets() {
575 let dir = tempfile::tempdir().unwrap();
576 let p = write_manifest(
577 dir.path(),
578 "install claude-code global\ninstall claude-code local",
579 );
580 let r = parse_manifest(&p).unwrap();
581 assert_eq!(r.manifest.install_targets.len(), 2);
582 assert_eq!(r.manifest.install_targets[0].scope, Scope::Global);
583 assert_eq!(r.manifest.install_targets[1].scope, Scope::Local);
584 }
585
586 #[test]
587 fn install_targets_not_in_entries() {
588 let dir = tempfile::tempdir().unwrap();
589 let p = write_manifest(
590 dir.path(),
591 "install claude-code global\ngithub agent owner/repo path/to/agent.md",
592 );
593 let r = parse_manifest(&p).unwrap();
594 assert_eq!(r.manifest.entries.len(), 1);
595 assert_eq!(r.manifest.install_targets.len(), 1);
596 }
597
598 #[test]
603 fn comments_and_blanks_skipped() {
604 let dir = tempfile::tempdir().unwrap();
605 let p = write_manifest(
606 dir.path(),
607 "# this is a comment\n\n# another comment\nlocal skill foo skills/foo.md",
608 );
609 let r = parse_manifest(&p).unwrap();
610 assert_eq!(r.manifest.entries.len(), 1);
611 }
612
613 #[test]
614 fn malformed_too_few_fields() {
615 let dir = tempfile::tempdir().unwrap();
616 let p = write_manifest(dir.path(), "github agent");
617 let r = parse_manifest(&p).unwrap();
618 assert!(r.manifest.entries.is_empty());
619 assert!(r.warnings.iter().any(|w| w.contains("warning")));
620 }
621
622 #[test]
623 fn unknown_source_type_skipped() {
624 let dir = tempfile::tempdir().unwrap();
625 let p = write_manifest(dir.path(), "svn skill foo some/path");
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 assert!(r.warnings.iter().any(|w| w.contains("svn")));
630 }
631
632 #[test]
637 fn inline_comment_stripped() {
638 let dir = tempfile::tempdir().unwrap();
639 let p = write_manifest(
640 dir.path(),
641 "github agent owner/repo agents/foo.md # my note",
642 );
643 let r = parse_manifest(&p).unwrap();
644 assert_eq!(r.manifest.entries.len(), 1);
645 let e = &r.manifest.entries[0];
646 assert_eq!(e.ref_(), "main"); assert_eq!(e.name, "foo");
648 }
649
650 #[test]
651 fn inline_comment_on_install_line() {
652 let dir = tempfile::tempdir().unwrap();
653 let p = write_manifest(dir.path(), "install claude-code global # primary target");
654 let r = parse_manifest(&p).unwrap();
655 assert_eq!(r.manifest.install_targets.len(), 1);
656 assert_eq!(r.manifest.install_targets[0].scope, Scope::Global);
657 }
658
659 #[test]
660 fn inline_comment_after_ref() {
661 let dir = tempfile::tempdir().unwrap();
662 let p = write_manifest(
663 dir.path(),
664 "github agent my-agent owner/repo agents/foo.md v1.0 # pinned version",
665 );
666 let r = parse_manifest(&p).unwrap();
667 assert_eq!(r.manifest.entries[0].ref_(), "v1.0");
668 }
669
670 #[test]
675 fn quoted_path_with_spaces() {
676 let dir = tempfile::tempdir().unwrap();
677 let p = dir.path().join(MANIFEST_NAME);
678 fs::write(&p, "local skill my-skill \"skills/my dir/foo.md\"\n").unwrap();
679 let r = parse_manifest(&p).unwrap();
680 assert_eq!(r.manifest.entries.len(), 1);
681 assert_eq!(r.manifest.entries[0].local_path(), "skills/my dir/foo.md");
682 }
683
684 #[test]
685 fn quoted_github_path() {
686 let dir = tempfile::tempdir().unwrap();
687 let p = dir.path().join(MANIFEST_NAME);
688 fs::write(
689 &p,
690 "github skill owner/repo \"path with spaces/skill.md\"\n",
691 )
692 .unwrap();
693 let r = parse_manifest(&p).unwrap();
694 assert_eq!(r.manifest.entries.len(), 1);
695 assert_eq!(
696 r.manifest.entries[0].path_in_repo(),
697 "path with spaces/skill.md"
698 );
699 }
700
701 #[test]
702 fn mixed_quoted_and_unquoted() {
703 let dir = tempfile::tempdir().unwrap();
704 let p = dir.path().join(MANIFEST_NAME);
705 fs::write(
706 &p,
707 "github agent my-agent owner/repo \"agents/path with spaces/foo.md\"\n",
708 )
709 .unwrap();
710 let r = parse_manifest(&p).unwrap();
711 assert_eq!(r.manifest.entries.len(), 1);
712 assert_eq!(r.manifest.entries[0].name, "my-agent");
713 assert_eq!(
714 r.manifest.entries[0].path_in_repo(),
715 "agents/path with spaces/foo.md"
716 );
717 }
718
719 #[test]
720 fn unquoted_fields_parse_identically() {
721 let dir = tempfile::tempdir().unwrap();
722 let p = write_manifest(
723 dir.path(),
724 "github agent backend-dev owner/repo path/to/agent.md main",
725 );
726 let r = parse_manifest(&p).unwrap();
727 assert_eq!(r.manifest.entries[0].name, "backend-dev");
728 assert_eq!(r.manifest.entries[0].ref_(), "main");
729 }
730
731 #[test]
736 fn valid_entry_name_accepted() {
737 let dir = tempfile::tempdir().unwrap();
738 let p = write_manifest(dir.path(), "local skill my-skill_v2.0 skills/foo.md");
739 let r = parse_manifest(&p).unwrap();
740 assert_eq!(r.manifest.entries.len(), 1);
741 assert_eq!(r.manifest.entries[0].name, "my-skill_v2.0");
742 }
743
744 #[test]
745 fn invalid_entry_name_rejected() {
746 let dir = tempfile::tempdir().unwrap();
747 let p = dir.path().join(MANIFEST_NAME);
748 fs::write(&p, "local skill \"my skill!\" skills/foo.md\n").unwrap();
749 let r = parse_manifest(&p).unwrap();
750 assert!(r.manifest.entries.is_empty());
751 assert!(r
752 .warnings
753 .iter()
754 .any(|w| w.to_lowercase().contains("invalid name")
755 || w.to_lowercase().contains("warning")));
756 }
757
758 #[test]
759 fn inferred_name_validated() {
760 let dir = tempfile::tempdir().unwrap();
761 let p = write_manifest(dir.path(), "local skill skills/foo.md");
762 let r = parse_manifest(&p).unwrap();
763 assert_eq!(r.manifest.entries.len(), 1);
764 assert_eq!(r.manifest.entries[0].name, "foo");
765 }
766
767 #[test]
772 fn valid_scope_accepted() {
773 for (scope_str, expected) in &[("global", Scope::Global), ("local", Scope::Local)] {
774 let dir = tempfile::tempdir().unwrap();
775 let p = write_manifest(dir.path(), &format!("install claude-code {scope_str}"));
776 let r = parse_manifest(&p).unwrap();
777 assert_eq!(r.manifest.install_targets.len(), 1);
778 assert_eq!(r.manifest.install_targets[0].scope, *expected);
779 }
780 }
781
782 #[test]
783 fn invalid_scope_rejected() {
784 let dir = tempfile::tempdir().unwrap();
785 let p = write_manifest(dir.path(), "install claude-code worldwide");
786 let r = parse_manifest(&p).unwrap();
787 assert!(r.manifest.install_targets.is_empty());
788 assert!(r
789 .warnings
790 .iter()
791 .any(|w| w.to_lowercase().contains("scope") || w.to_lowercase().contains("warning")));
792 }
793
794 #[test]
799 fn duplicate_entry_name_warns() {
800 let dir = tempfile::tempdir().unwrap();
801 let p = write_manifest(
802 dir.path(),
803 "local skill foo skills/foo.md\nlocal agent foo agents/foo.md",
804 );
805 let r = parse_manifest(&p).unwrap();
806 assert_eq!(r.manifest.entries.len(), 2); assert!(r
808 .warnings
809 .iter()
810 .any(|w| w.to_lowercase().contains("duplicate")));
811 }
812
813 #[test]
818 fn utf8_bom_handled() {
819 let dir = tempfile::tempdir().unwrap();
820 let p = dir.path().join(MANIFEST_NAME);
821 let mut content = vec![0xEF, 0xBB, 0xBF]; content.extend_from_slice(b"install claude-code global\n");
823 fs::write(&p, content).unwrap();
824 let r = parse_manifest(&p).unwrap();
825 assert_eq!(r.manifest.install_targets.len(), 1);
826 assert_eq!(
827 r.manifest.install_targets[0],
828 InstallTarget {
829 adapter: "claude-code".into(),
830 scope: Scope::Global,
831 }
832 );
833 }
834
835 #[test]
840 fn unknown_entity_type_skipped_with_warning() {
841 let dir = tempfile::tempdir().unwrap();
842 let p = write_manifest(dir.path(), "local hook foo hooks/foo.md");
843 let r = parse_manifest(&p).unwrap();
844 assert!(r.manifest.entries.is_empty());
845 assert!(r.warnings.iter().any(|w| w.contains("unknown entity type")));
846 }
847
848 #[test]
853 fn find_entry_in_found() {
854 let e = Entry {
855 entity_type: EntityType::Skill,
856 name: "foo".into(),
857 source: SourceFields::Local {
858 path: "foo.md".into(),
859 },
860 };
861 let m = Manifest {
862 entries: vec![e.clone()],
863 install_targets: vec![],
864 };
865 assert_eq!(find_entry_in("foo", &m).unwrap(), &e);
866 }
867
868 #[test]
869 fn find_entry_in_not_found() {
870 let m = Manifest::default();
871 assert!(find_entry_in("missing", &m).is_err());
872 }
873
874 #[test]
879 fn infer_name_from_md_path() {
880 assert_eq!(infer_name("path/to/agent.md"), "agent");
881 }
882
883 #[test]
884 fn infer_name_from_dot() {
885 assert_eq!(infer_name("."), "content");
886 }
887
888 #[test]
889 fn infer_name_from_url() {
890 assert_eq!(infer_name("https://example.com/my-skill.md"), "my-skill");
891 }
892
893 #[test]
898 fn split_line_simple() {
899 assert_eq!(
900 split_line("github agent owner/repo agent.md"),
901 vec!["github", "agent", "owner/repo", "agent.md"]
902 );
903 }
904
905 #[test]
906 fn split_line_quoted() {
907 assert_eq!(
908 split_line("local skill \"my dir/foo.md\""),
909 vec!["local", "skill", "my dir/foo.md"]
910 );
911 }
912
913 #[test]
914 fn split_line_tabs() {
915 assert_eq!(
916 split_line("local\tskill\tfoo.md"),
917 vec!["local", "skill", "foo.md"]
918 );
919 }
920}