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
228pub fn parse_manifest(manifest_path: &Path) -> Result<ParseResult, SkillfileError> {
230 let raw_bytes = std::fs::read(manifest_path)?;
231
232 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; 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 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#[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
369pub 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 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 #[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 #[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 #[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 #[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 #[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 #[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"); 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 #[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 #[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 #[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 #[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); assert!(r
784 .warnings
785 .iter()
786 .any(|w| w.to_lowercase().contains("duplicate")));
787 }
788
789 #[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]; 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 #[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 #[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 #[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 #[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}