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