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