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", "gitlab", "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>) {
44 if !current.is_empty() {
45 parts.push(std::mem::take(current));
46 }
47}
48
49fn split_line(line: &str) -> Vec<String> {
54 let mut parts = Vec::new();
55 let mut current = String::new();
56 let mut in_quotes = false;
57
58 for ch in line.chars() {
59 if ch == '"' {
60 in_quotes = !in_quotes;
61 continue;
62 }
63 if ch.is_whitespace() && !in_quotes {
64 flush_token(&mut current, &mut parts);
65 continue;
66 }
67 current.push(ch);
68 }
69 flush_token(&mut current, &mut parts);
70 parts
71}
72
73fn strip_inline_comment(parts: Vec<String>) -> Vec<String> {
74 if let Some(pos) = parts.iter().position(|p| p.starts_with('#')) {
75 parts[..pos].to_vec()
76 } else {
77 parts
78 }
79}
80
81#[must_use]
88pub fn parse_owner_repo_ref(input: &str) -> (String, Option<String>) {
89 match input.split_once('@') {
90 Some((repo, ref_)) if !repo.is_empty() && !ref_.is_empty() => {
91 (repo.to_string(), Some(ref_.to_string()))
92 }
93 _ => (input.to_string(), None),
94 }
95}
96
97#[must_use]
101pub fn resolve_explicit_owner_repo_ref(
102 at_ref: Option<String>,
103 positional_ref: Option<&str>,
104) -> Option<String> {
105 at_ref.or_else(|| positional_ref.map(String::from))
106}
107
108#[must_use]
112pub fn resolve_owner_repo_ref(at_ref: Option<String>, positional_ref: Option<&str>) -> String {
113 resolve_explicit_owner_repo_ref(at_ref, positional_ref)
114 .unwrap_or_else(|| DEFAULT_REF.to_string())
115}
116
117fn parse_github_owner_repo(
118 raw_owner_repo: &str,
119 lineno: usize,
120 warnings: &mut Vec<String>,
121) -> Option<(String, Option<String>)> {
122 let (owner_repo, at_ref) = parse_owner_repo_ref(raw_owner_repo);
123 if owner_repo.contains('/') {
124 return Some((owner_repo, at_ref));
125 }
126 warnings.push(format!(
127 "warning: line {lineno}: invalid owner/repo '{raw_owner_repo}' \
128 — expected 'owner/repo' or 'owner/repo@ref' format"
129 ));
130 None
131}
132
133fn parse_github_entry(
135 parts: &[String],
136 entity_type: EntityType,
137 lineno: usize,
138) -> (Option<Entry>, Vec<String>) {
139 let mut warnings = Vec::new();
140
141 let (name, owner_repo, path_in_repo, ref_) = if parts[2].contains('/') {
143 if parts.len() < 4 {
144 warnings.push(format!(
145 "warning: line {lineno}: github entry needs at least: owner/repo path"
146 ));
147 return (None, warnings);
148 }
149 let Some((parsed_repo, parsed_ref)) =
150 parse_github_owner_repo(&parts[2], lineno, &mut warnings)
151 else {
152 return (None, warnings);
153 };
154 let ref_ = resolve_owner_repo_ref(parsed_ref, parts.get(4).map(String::as_str));
155 (infer_name(&parts[3]), parsed_repo, &parts[3], ref_)
156 } else {
157 if parts.len() < 5 {
158 warnings.push(format!(
159 "warning: line {lineno}: github entry needs at least: name owner/repo path"
160 ));
161 return (None, warnings);
162 }
163 let Some((parsed_repo, parsed_ref)) =
164 parse_github_owner_repo(&parts[3], lineno, &mut warnings)
165 else {
166 return (None, warnings);
167 };
168 let ref_ = resolve_owner_repo_ref(parsed_ref, parts.get(5).map(String::as_str));
169 (parts[2].clone(), parsed_repo, &parts[4], ref_)
170 };
171
172 let entry = Entry {
173 entity_type,
174 name,
175 source: SourceFields::Github {
176 owner_repo,
177 path_in_repo: path_in_repo.clone(),
178 ref_,
179 },
180 };
181 (Some(entry), warnings)
182}
183
184fn parse_gitlab_entry(
186 parts: &[String],
187 entity_type: EntityType,
188 lineno: usize,
189) -> (Option<Entry>, Vec<String>) {
190 let mut warnings = Vec::new();
191
192 let (name, owner_repo, path_in_repo, ref_) = if parts[2].contains('/') {
193 if parts.len() < 4 {
194 warnings.push(format!(
195 "warning: line {lineno}: gitlab entry needs at least: owner/repo path"
196 ));
197 return (None, warnings);
198 }
199 let ref_ = parts.get(4).map_or(DEFAULT_REF, String::as_str);
200 (infer_name(&parts[3]), &parts[2], &parts[3], ref_)
201 } else {
202 if parts.len() < 5 {
203 warnings.push(format!(
204 "warning: line {lineno}: gitlab entry needs at least: name owner/repo path"
205 ));
206 return (None, warnings);
207 }
208 if !parts[3].contains('/') {
209 warnings.push(format!(
210 "warning: line {lineno}: invalid owner/repo '{}' \
211 — expected 'owner/repo' format",
212 parts[3],
213 ));
214 return (None, warnings);
215 }
216 let ref_ = parts.get(5).map_or(DEFAULT_REF, String::as_str);
217 (parts[2].clone(), &parts[3], &parts[4], ref_)
218 };
219
220 let entry = Entry {
221 entity_type,
222 name,
223 source: SourceFields::Gitlab {
224 owner_repo: owner_repo.clone(),
225 path_in_repo: path_in_repo.clone(),
226 ref_: ref_.to_owned(),
227 },
228 };
229 (Some(entry), warnings)
230}
231
232fn parse_local_entry(parts: &[String], entity_type: EntityType) -> (Option<Entry>, Vec<String>) {
233 let warnings = Vec::new();
234
235 let looks_like_path = Path::new(&parts[2])
240 .extension()
241 .is_some_and(|e| e.eq_ignore_ascii_case("md"))
242 || parts[2].contains('/');
243 if looks_like_path || parts.len() < 4 {
244 let local_path = &parts[2];
245 let name = infer_name(local_path);
246 (
247 Some(Entry {
248 entity_type,
249 name,
250 source: SourceFields::Local {
251 path: local_path.clone(),
252 },
253 }),
254 warnings,
255 )
256 } else {
257 let name = &parts[2];
258 let local_path = &parts[3];
259 (
260 Some(Entry {
261 entity_type,
262 name: name.clone(),
263 source: SourceFields::Local {
264 path: local_path.clone(),
265 },
266 }),
267 warnings,
268 )
269 }
270}
271
272fn parse_url_entry(
273 parts: &[String],
274 entity_type: EntityType,
275 lineno: usize,
276) -> (Option<Entry>, Vec<String>) {
277 let mut warnings = Vec::new();
278
279 if parts[2].starts_with("http") {
281 let url = &parts[2];
282 let name = infer_name(url);
283 (
284 Some(Entry {
285 entity_type,
286 name,
287 source: SourceFields::Url { url: url.clone() },
288 }),
289 warnings,
290 )
291 } else {
292 if parts.len() < 4 {
293 warnings.push(format!("warning: line {lineno}: url entry needs: name url"));
294 return (None, warnings);
295 }
296 let name = &parts[2];
297 let url = &parts[3];
298 (
299 Some(Entry {
300 entity_type,
301 name: name.clone(),
302 source: SourceFields::Url { url: url.clone() },
303 }),
304 warnings,
305 )
306 }
307}
308
309struct ParseAccumulator {
310 entries: Vec<Entry>,
311 install_targets: Vec<InstallTarget>,
312 warnings: Vec<String>,
313 seen_names: HashSet<String>,
314}
315
316fn parse_install_line(parts: &[String], lineno: usize, acc: &mut ParseAccumulator) {
317 if parts.len() < 3 {
318 acc.warnings.push(format!(
319 "warning: line {lineno}: install line needs: adapter scope"
320 ));
321 return;
322 }
323 let scope_str = &parts[2];
324 if let Some(scope) = Scope::parse(scope_str) {
325 acc.install_targets.push(InstallTarget {
326 adapter: parts[1].clone(),
327 scope,
328 });
329 } else {
330 let valid: Vec<&str> = Scope::ALL
331 .iter()
332 .map(super::models::Scope::as_str)
333 .collect();
334 acc.warnings.push(format!(
335 "warning: line {lineno}: invalid scope '{scope_str}', \
336 must be one of: {}",
337 valid.join(", ")
338 ));
339 }
340}
341
342fn validate_and_push_entry(entry: Entry, lineno: usize, acc: &mut ParseAccumulator) {
343 if !is_valid_name(&entry.name) {
344 acc.warnings.push(format!(
345 "warning: line {lineno}: invalid name '{}' \
346 — names must match [a-zA-Z0-9._-], skipping",
347 entry.name
348 ));
349 } else if acc.seen_names.contains(&entry.name) {
350 acc.warnings.push(format!(
351 "warning: line {lineno}: duplicate entry name '{}'",
352 entry.name
353 ));
354 acc.entries.push(entry);
355 } else {
356 acc.seen_names.insert(entry.name.clone());
357 acc.entries.push(entry);
358 }
359}
360
361fn parse_source_entry(
362 parts: &[String],
363 lineno: usize,
364 source_type: &str,
365) -> (Option<Entry>, Vec<String>) {
366 if parts.len() < 3 {
367 return (
368 None,
369 vec![format!("warning: line {lineno}: too few fields, skipping")],
370 );
371 }
372 let Some(entity_type) = EntityType::parse(&parts[1]) else {
373 return (
374 None,
375 vec![format!(
376 "warning: line {lineno}: unknown entity type '{}', skipping",
377 parts[1]
378 )],
379 );
380 };
381 match source_type {
382 "github" => parse_github_entry(parts, entity_type, lineno),
383 "gitlab" => parse_gitlab_entry(parts, entity_type, lineno),
384 "local" => parse_local_entry(parts, entity_type),
385 "url" => parse_url_entry(parts, entity_type, lineno),
386 _ => (None, vec![]),
387 }
388}
389
390fn process_source_line(parts: &[String], lineno: usize, acc: &mut ParseAccumulator) {
391 let source_type = parts[0].as_str();
392 let (entry_opt, mut entry_warnings) = parse_source_entry(parts, lineno, source_type);
393 acc.warnings.append(&mut entry_warnings);
394 if let Some(entry) = entry_opt {
395 validate_and_push_entry(entry, lineno, acc);
396 }
397}
398
399pub fn parse_manifest(manifest_path: &Path) -> Result<ParseResult, SkillfileError> {
400 let raw_bytes = std::fs::read(manifest_path)?;
401
402 let text = if raw_bytes.starts_with(&[0xEF, 0xBB, 0xBF]) {
404 String::from_utf8_lossy(&raw_bytes[3..]).into_owned()
405 } else {
406 String::from_utf8_lossy(&raw_bytes).into_owned()
407 };
408
409 let mut acc = ParseAccumulator {
410 entries: Vec::new(),
411 install_targets: Vec::new(),
412 warnings: Vec::new(),
413 seen_names: HashSet::new(),
414 };
415
416 for (lineno, raw) in text.lines().enumerate() {
417 let lineno = lineno + 1; let line = raw.trim();
419 if line.is_empty() || line.starts_with('#') {
420 continue;
421 }
422
423 let parts = strip_inline_comment(split_line(line));
424 if parts.len() < 2 {
425 acc.warnings
426 .push(format!("warning: line {lineno}: too few fields, skipping"));
427 continue;
428 }
429
430 match parts[0].as_str() {
431 "install" => parse_install_line(&parts, lineno, &mut acc),
432 _ if KNOWN_SOURCES.contains(&parts[0].as_str()) => {
433 process_source_line(&parts, lineno, &mut acc);
434 }
435 st => {
436 acc.warnings.push(format!(
437 "warning: line {lineno}: unknown source type '{st}', skipping"
438 ));
439 }
440 }
441 }
442
443 Ok(ParseResult {
444 manifest: Manifest {
445 entries: acc.entries,
446 install_targets: acc.install_targets,
447 },
448 warnings: acc.warnings,
449 })
450}
451
452#[must_use]
453pub fn parse_manifest_line(line: &str) -> Option<Entry> {
454 let parts = split_line(line);
455 let parts = strip_inline_comment(parts);
456 if parts.len() < 3 {
457 return None;
458 }
459 let source_type = parts[0].as_str();
460 if !KNOWN_SOURCES.contains(&source_type) || source_type == "install" {
461 return None;
462 }
463 let entity_type = EntityType::parse(&parts[1])?;
464 let (entry_opt, _) = match source_type {
465 "github" => parse_github_entry(&parts, entity_type, 0),
466 "gitlab" => parse_gitlab_entry(&parts, entity_type, 0),
467 "local" => parse_local_entry(&parts, entity_type),
468 "url" => parse_url_entry(&parts, entity_type, 0),
469 _ => return None,
470 };
471 entry_opt
472}
473
474pub fn find_entry_in<'a>(name: &str, manifest: &'a Manifest) -> Result<&'a Entry, SkillfileError> {
475 manifest
476 .entries
477 .iter()
478 .find(|e| e.name == name)
479 .ok_or_else(|| {
480 SkillfileError::Manifest(format!("no entry named '{name}' in {MANIFEST_NAME}"))
481 })
482}
483
484#[cfg(test)]
485mod tests {
486 use super::*;
487 use std::fs;
488
489 fn dedent_line(line: &str, indent: usize) -> &str {
490 if line.len() >= indent {
491 &line[indent..]
492 } else {
493 line.trim()
494 }
495 }
496
497 fn write_manifest(dir: &Path, content: &str) -> std::path::PathBuf {
498 let p = dir.join(MANIFEST_NAME);
499 let lines: Vec<&str> = content.lines().collect();
501 let min_indent = lines
502 .iter()
503 .filter(|l| !l.trim().is_empty())
504 .map(|l| l.len() - l.trim_start().len())
505 .min()
506 .unwrap_or(0);
507 let dedented: String = lines
508 .iter()
509 .map(|l| dedent_line(l, min_indent))
510 .collect::<Vec<_>>()
511 .join("\n");
512 fs::write(&p, dedented.trim_start_matches('\n').to_string() + "\n").unwrap();
513 p
514 }
515
516 #[test]
521 fn github_entry_explicit_name_and_ref() {
522 let dir = tempfile::tempdir().unwrap();
523 let p = write_manifest(
524 dir.path(),
525 "github agent backend-dev owner/repo path/to/agent.md main",
526 );
527 let r = parse_manifest(&p).unwrap();
528 assert_eq!(r.manifest.entries.len(), 1);
529 let e = &r.manifest.entries[0];
530 assert_eq!(e.source_type(), "github");
531 assert_eq!(e.entity_type, EntityType::Agent);
532 assert_eq!(e.name, "backend-dev");
533 assert_eq!(e.owner_repo(), "owner/repo");
534 assert_eq!(e.path_in_repo(), "path/to/agent.md");
535 assert_eq!(e.ref_(), "main");
536 }
537
538 #[test]
539 fn local_entry_bare_dir_name() {
540 let dir = tempfile::tempdir().unwrap();
541 let p = write_manifest(dir.path(), "local skill bash-craftsman");
542 let r = parse_manifest(&p).unwrap();
543 assert!(
544 r.warnings.is_empty(),
545 "unexpected warnings: {:?}",
546 r.warnings
547 );
548 assert_eq!(r.manifest.entries.len(), 1);
549 let e = &r.manifest.entries[0];
550 assert_eq!(e.source_type(), "local");
551 assert_eq!(e.entity_type, EntityType::Skill);
552 assert_eq!(e.name, "bash-craftsman");
553 assert_eq!(e.local_path(), "bash-craftsman");
554 }
555
556 #[test]
557 fn local_entry_explicit_name() {
558 let dir = tempfile::tempdir().unwrap();
559 let p = write_manifest(dir.path(), "local skill git-commit skills/git/commit.md");
560 let r = parse_manifest(&p).unwrap();
561 assert_eq!(r.manifest.entries.len(), 1);
562 let e = &r.manifest.entries[0];
563 assert_eq!(e.source_type(), "local");
564 assert_eq!(e.entity_type, EntityType::Skill);
565 assert_eq!(e.name, "git-commit");
566 assert_eq!(e.local_path(), "skills/git/commit.md");
567 }
568
569 #[test]
570 fn url_entry_explicit_name() {
571 let dir = tempfile::tempdir().unwrap();
572 let p = write_manifest(
573 dir.path(),
574 "url skill my-skill https://example.com/skill.md",
575 );
576 let r = parse_manifest(&p).unwrap();
577 assert_eq!(r.manifest.entries.len(), 1);
578 let e = &r.manifest.entries[0];
579 assert_eq!(e.source_type(), "url");
580 assert_eq!(e.name, "my-skill");
581 assert_eq!(e.url(), "https://example.com/skill.md");
582 }
583
584 #[test]
589 fn github_entry_inferred_name() {
590 let dir = tempfile::tempdir().unwrap();
591 let p = write_manifest(
592 dir.path(),
593 "github agent owner/repo path/to/agent.md main",
594 );
595 let r = parse_manifest(&p).unwrap();
596 assert_eq!(r.manifest.entries.len(), 1);
597 let e = &r.manifest.entries[0];
598 assert_eq!(e.name, "agent");
599 assert_eq!(e.owner_repo(), "owner/repo");
600 assert_eq!(e.path_in_repo(), "path/to/agent.md");
601 assert_eq!(e.ref_(), "main");
602 }
603
604 #[test]
605 fn local_entry_inferred_name_from_path() {
606 let dir = tempfile::tempdir().unwrap();
607 let p = write_manifest(dir.path(), "local skill skills/git/commit.md");
608 let r = parse_manifest(&p).unwrap();
609 assert_eq!(r.manifest.entries.len(), 1);
610 let e = &r.manifest.entries[0];
611 assert_eq!(e.name, "commit");
612 assert_eq!(e.local_path(), "skills/git/commit.md");
613 }
614
615 #[test]
616 fn local_entry_inferred_name_from_md_extension() {
617 let dir = tempfile::tempdir().unwrap();
618 let p = write_manifest(dir.path(), "local skill commit.md");
619 let r = parse_manifest(&p).unwrap();
620 assert_eq!(r.manifest.entries.len(), 1);
621 assert_eq!(r.manifest.entries[0].name, "commit");
622 }
623
624 #[test]
625 fn url_entry_inferred_name() {
626 let dir = tempfile::tempdir().unwrap();
627 let p = write_manifest(dir.path(), "url skill https://example.com/my-skill.md");
628 let r = parse_manifest(&p).unwrap();
629 assert_eq!(r.manifest.entries.len(), 1);
630 let e = &r.manifest.entries[0];
631 assert_eq!(e.name, "my-skill");
632 assert_eq!(e.url(), "https://example.com/my-skill.md");
633 }
634
635 #[test]
640 fn github_entry_inferred_name_default_ref() {
641 let dir = tempfile::tempdir().unwrap();
642 let p = write_manifest(dir.path(), "github agent owner/repo path/to/agent.md");
643 let r = parse_manifest(&p).unwrap();
644 assert_eq!(r.manifest.entries[0].ref_(), "main");
645 }
646
647 #[test]
648 fn github_entry_explicit_name_default_ref() {
649 let dir = tempfile::tempdir().unwrap();
650 let p = write_manifest(
651 dir.path(),
652 "github agent my-agent owner/repo path/to/agent.md",
653 );
654 let r = parse_manifest(&p).unwrap();
655 assert_eq!(r.manifest.entries[0].ref_(), "main");
656 }
657
658 #[test]
663 fn github_entry_at_ref_inferred_name() {
664 let dir = tempfile::tempdir().unwrap();
665 let p = write_manifest(dir.path(), "github skill nuxt/ui@v4 path/to/SKILL.md");
666 let r = parse_manifest(&p).unwrap();
667 assert_eq!(r.manifest.entries.len(), 1);
668 let e = &r.manifest.entries[0];
669 assert_eq!(e.name, "SKILL");
670 assert_eq!(e.owner_repo(), "nuxt/ui");
671 assert_eq!(e.ref_(), "v4");
672 }
673
674 #[test]
675 fn github_entry_at_ref_explicit_name() {
676 let dir = tempfile::tempdir().unwrap();
677 let p = write_manifest(
678 dir.path(),
679 "github skill my-skill nuxt/ui@v4 path/to/SKILL.md",
680 );
681 let r = parse_manifest(&p).unwrap();
682 assert_eq!(r.manifest.entries.len(), 1);
683 let e = &r.manifest.entries[0];
684 assert_eq!(e.name, "my-skill");
685 assert_eq!(e.owner_repo(), "nuxt/ui");
686 assert_eq!(e.ref_(), "v4");
687 }
688
689 #[test]
690 fn github_entry_at_ref_with_main() {
691 let dir = tempfile::tempdir().unwrap();
692 let p = write_manifest(
693 dir.path(),
694 "github skill owner/repo@main path/to/SKILL.md",
695 );
696 let r = parse_manifest(&p).unwrap();
697 assert_eq!(r.manifest.entries[0].owner_repo(), "owner/repo");
698 assert_eq!(r.manifest.entries[0].ref_(), "main");
699 }
700
701 #[test]
702 fn github_entry_at_ref_with_sha() {
703 let dir = tempfile::tempdir().unwrap();
704 let p = write_manifest(
705 dir.path(),
706 "github skill owner/repo@abc123def456 path/to/SKILL.md",
707 );
708 let r = parse_manifest(&p).unwrap();
709 assert_eq!(r.manifest.entries[0].owner_repo(), "owner/repo");
710 assert_eq!(r.manifest.entries[0].ref_(), "abc123def456");
711 }
712
713 #[test]
714 fn github_entry_at_ref_takes_priority_over_positional() {
715 let dir = tempfile::tempdir().unwrap();
716 let p = write_manifest(
717 dir.path(),
718 "github skill nuxt/ui@v4 path/to/SKILL.md v3",
719 );
720 let r = parse_manifest(&p).unwrap();
721 let e = &r.manifest.entries[0];
722 assert_eq!(e.owner_repo(), "nuxt/ui");
723 assert_eq!(e.ref_(), "v4");
724 }
725
726 #[test]
727 fn github_entry_at_ref_requires_owner_repo_before_ref_separator() {
728 let dir = tempfile::tempdir().unwrap();
729 let p = write_manifest(dir.path(), "github skill us@tal/repo path/to/SKILL.md");
730 let r = parse_manifest(&p).unwrap();
731 assert!(r.manifest.entries.is_empty());
732 assert!(r
733 .warnings
734 .iter()
735 .any(|warning| warning.contains("invalid owner/repo 'us@tal/repo'")));
736 }
737
738 #[test]
739 fn github_entry_at_ref_requires_owner_repo_before_ref_separator_with_name() {
740 let dir = tempfile::tempdir().unwrap();
741 let p = write_manifest(
742 dir.path(),
743 "github skill my-skill us@tal/repo path/to/SKILL.md",
744 );
745 let r = parse_manifest(&p).unwrap();
746 assert!(r.manifest.entries.is_empty());
747 assert!(r
748 .warnings
749 .iter()
750 .any(|warning| warning.contains("invalid owner/repo 'us@tal/repo'")));
751 }
752
753 #[test]
758 fn install_target_parsed() {
759 let dir = tempfile::tempdir().unwrap();
760 let p = write_manifest(dir.path(), "install claude-code global");
761 let r = parse_manifest(&p).unwrap();
762 assert_eq!(r.manifest.install_targets.len(), 1);
763 let t = &r.manifest.install_targets[0];
764 assert_eq!(t.adapter, "claude-code");
765 assert_eq!(t.scope, Scope::Global);
766 }
767
768 #[test]
769 fn multiple_install_targets() {
770 let dir = tempfile::tempdir().unwrap();
771 let p = write_manifest(
772 dir.path(),
773 "install claude-code global\ninstall claude-code local",
774 );
775 let r = parse_manifest(&p).unwrap();
776 assert_eq!(r.manifest.install_targets.len(), 2);
777 assert_eq!(r.manifest.install_targets[0].scope, Scope::Global);
778 assert_eq!(r.manifest.install_targets[1].scope, Scope::Local);
779 }
780
781 #[test]
782 fn install_targets_not_in_entries() {
783 let dir = tempfile::tempdir().unwrap();
784 let p = write_manifest(
785 dir.path(),
786 "install claude-code global\ngithub agent owner/repo path/to/agent.md",
787 );
788 let r = parse_manifest(&p).unwrap();
789 assert_eq!(r.manifest.entries.len(), 1);
790 assert_eq!(r.manifest.install_targets.len(), 1);
791 }
792
793 #[test]
798 fn comments_and_blanks_skipped() {
799 let dir = tempfile::tempdir().unwrap();
800 let p = write_manifest(
801 dir.path(),
802 "# this is a comment\n\n# another comment\nlocal skill foo skills/foo.md",
803 );
804 let r = parse_manifest(&p).unwrap();
805 assert_eq!(r.manifest.entries.len(), 1);
806 }
807
808 #[test]
809 fn malformed_too_few_fields() {
810 let dir = tempfile::tempdir().unwrap();
811 let p = write_manifest(dir.path(), "github agent");
812 let r = parse_manifest(&p).unwrap();
813 assert!(r.manifest.entries.is_empty());
814 assert!(r.warnings.iter().any(|w| w.contains("warning")));
815 }
816
817 #[test]
818 fn unknown_source_type_skipped() {
819 let dir = tempfile::tempdir().unwrap();
820 let p = write_manifest(dir.path(), "svn skill foo some/path");
821 let r = parse_manifest(&p).unwrap();
822 assert!(r.manifest.entries.is_empty());
823 assert!(r.warnings.iter().any(|w| w.contains("warning")));
824 assert!(r.warnings.iter().any(|w| w.contains("svn")));
825 }
826
827 #[test]
832 fn inline_comment_stripped() {
833 let dir = tempfile::tempdir().unwrap();
834 let p = write_manifest(
835 dir.path(),
836 "github agent owner/repo agents/foo.md # my note",
837 );
838 let r = parse_manifest(&p).unwrap();
839 assert_eq!(r.manifest.entries.len(), 1);
840 let e = &r.manifest.entries[0];
841 assert_eq!(e.ref_(), "main"); assert_eq!(e.name, "foo");
843 }
844
845 #[test]
846 fn inline_comment_on_install_line() {
847 let dir = tempfile::tempdir().unwrap();
848 let p = write_manifest(dir.path(), "install claude-code global # primary target");
849 let r = parse_manifest(&p).unwrap();
850 assert_eq!(r.manifest.install_targets.len(), 1);
851 assert_eq!(r.manifest.install_targets[0].scope, Scope::Global);
852 }
853
854 #[test]
855 fn inline_comment_after_ref() {
856 let dir = tempfile::tempdir().unwrap();
857 let p = write_manifest(
858 dir.path(),
859 "github agent my-agent owner/repo agents/foo.md v1.0 # pinned version",
860 );
861 let r = parse_manifest(&p).unwrap();
862 assert_eq!(r.manifest.entries[0].ref_(), "v1.0");
863 }
864
865 #[test]
870 fn quoted_path_with_spaces() {
871 let dir = tempfile::tempdir().unwrap();
872 let p = dir.path().join(MANIFEST_NAME);
873 fs::write(&p, "local skill my-skill \"skills/my dir/foo.md\"\n").unwrap();
874 let r = parse_manifest(&p).unwrap();
875 assert_eq!(r.manifest.entries.len(), 1);
876 assert_eq!(r.manifest.entries[0].local_path(), "skills/my dir/foo.md");
877 }
878
879 #[test]
880 fn quoted_github_path() {
881 let dir = tempfile::tempdir().unwrap();
882 let p = dir.path().join(MANIFEST_NAME);
883 fs::write(
884 &p,
885 "github skill owner/repo \"path with spaces/skill.md\"\n",
886 )
887 .unwrap();
888 let r = parse_manifest(&p).unwrap();
889 assert_eq!(r.manifest.entries.len(), 1);
890 assert_eq!(
891 r.manifest.entries[0].path_in_repo(),
892 "path with spaces/skill.md"
893 );
894 }
895
896 #[test]
897 fn mixed_quoted_and_unquoted() {
898 let dir = tempfile::tempdir().unwrap();
899 let p = dir.path().join(MANIFEST_NAME);
900 fs::write(
901 &p,
902 "github agent my-agent owner/repo \"agents/path with spaces/foo.md\"\n",
903 )
904 .unwrap();
905 let r = parse_manifest(&p).unwrap();
906 assert_eq!(r.manifest.entries.len(), 1);
907 assert_eq!(r.manifest.entries[0].name, "my-agent");
908 assert_eq!(
909 r.manifest.entries[0].path_in_repo(),
910 "agents/path with spaces/foo.md"
911 );
912 }
913
914 #[test]
915 fn unquoted_fields_parse_identically() {
916 let dir = tempfile::tempdir().unwrap();
917 let p = write_manifest(
918 dir.path(),
919 "github agent backend-dev owner/repo path/to/agent.md main",
920 );
921 let r = parse_manifest(&p).unwrap();
922 assert_eq!(r.manifest.entries[0].name, "backend-dev");
923 assert_eq!(r.manifest.entries[0].ref_(), "main");
924 }
925
926 #[test]
931 fn valid_entry_name_accepted() {
932 let dir = tempfile::tempdir().unwrap();
933 let p = write_manifest(dir.path(), "local skill my-skill_v2.0 skills/foo.md");
934 let r = parse_manifest(&p).unwrap();
935 assert_eq!(r.manifest.entries.len(), 1);
936 assert_eq!(r.manifest.entries[0].name, "my-skill_v2.0");
937 }
938
939 #[test]
940 fn invalid_entry_name_rejected() {
941 let dir = tempfile::tempdir().unwrap();
942 let p = dir.path().join(MANIFEST_NAME);
943 fs::write(&p, "local skill \"my skill!\" skills/foo.md\n").unwrap();
944 let r = parse_manifest(&p).unwrap();
945 assert!(r.manifest.entries.is_empty());
946 assert!(r
947 .warnings
948 .iter()
949 .any(|w| w.to_lowercase().contains("invalid name")
950 || w.to_lowercase().contains("warning")));
951 }
952
953 #[test]
954 fn inferred_name_validated() {
955 let dir = tempfile::tempdir().unwrap();
956 let p = write_manifest(dir.path(), "local skill skills/foo.md");
957 let r = parse_manifest(&p).unwrap();
958 assert_eq!(r.manifest.entries.len(), 1);
959 assert_eq!(r.manifest.entries[0].name, "foo");
960 }
961
962 #[test]
967 fn valid_scope_accepted() {
968 for (scope_str, expected) in &[("global", Scope::Global), ("local", Scope::Local)] {
969 let dir = tempfile::tempdir().unwrap();
970 let p = write_manifest(dir.path(), &format!("install claude-code {scope_str}"));
971 let r = parse_manifest(&p).unwrap();
972 assert_eq!(r.manifest.install_targets.len(), 1);
973 assert_eq!(r.manifest.install_targets[0].scope, *expected);
974 }
975 }
976
977 #[test]
978 fn invalid_scope_rejected() {
979 let dir = tempfile::tempdir().unwrap();
980 let p = write_manifest(dir.path(), "install claude-code worldwide");
981 let r = parse_manifest(&p).unwrap();
982 assert!(r.manifest.install_targets.is_empty());
983 assert!(r
984 .warnings
985 .iter()
986 .any(|w| w.to_lowercase().contains("scope") || w.to_lowercase().contains("warning")));
987 }
988
989 #[test]
994 fn duplicate_entry_name_warns() {
995 let dir = tempfile::tempdir().unwrap();
996 let p = write_manifest(
997 dir.path(),
998 "local skill foo skills/foo.md\nlocal agent foo agents/foo.md",
999 );
1000 let r = parse_manifest(&p).unwrap();
1001 assert_eq!(r.manifest.entries.len(), 2); assert!(r
1003 .warnings
1004 .iter()
1005 .any(|w| w.to_lowercase().contains("duplicate")));
1006 }
1007
1008 #[test]
1013 fn utf8_bom_handled() {
1014 let dir = tempfile::tempdir().unwrap();
1015 let p = dir.path().join(MANIFEST_NAME);
1016 let mut content = vec![0xEF, 0xBB, 0xBF]; content.extend_from_slice(b"install claude-code global\n");
1018 fs::write(&p, content).unwrap();
1019 let r = parse_manifest(&p).unwrap();
1020 assert_eq!(r.manifest.install_targets.len(), 1);
1021 assert_eq!(
1022 r.manifest.install_targets[0],
1023 InstallTarget {
1024 adapter: "claude-code".into(),
1025 scope: Scope::Global,
1026 }
1027 );
1028 }
1029
1030 #[test]
1035 fn unknown_entity_type_skipped_with_warning() {
1036 let dir = tempfile::tempdir().unwrap();
1037 let p = write_manifest(dir.path(), "local hook foo hooks/foo.md");
1038 let r = parse_manifest(&p).unwrap();
1039 assert!(r.manifest.entries.is_empty());
1040 assert!(r.warnings.iter().any(|w| w.contains("unknown entity type")));
1041 }
1042
1043 #[test]
1044 fn github_invalid_owner_repo_skipped_with_warning() {
1045 let dir = tempfile::tempdir().unwrap();
1046 let p = write_manifest(dir.path(), "github skill my-skill noslash path.md");
1048 let r = parse_manifest(&p).unwrap();
1049 assert!(
1050 r.manifest.entries.is_empty(),
1051 "entry with invalid owner/repo should be skipped"
1052 );
1053 assert!(r.warnings.iter().any(|w| w.contains("owner/repo")));
1054 }
1055
1056 #[test]
1057 fn github_invalid_owner_repo_after_lossy_utf8_decode_skipped_with_warning() {
1058 let dir = tempfile::tempdir().unwrap();
1059 let p = dir.path().join(MANIFEST_NAME);
1060 fs::write(
1061 &p,
1062 [
1063 240, 174, 174, 174, 240, 174, 174, 170, 240, 105, 116, 104, 117, 97, 10, 103, 105,
1064 116, 104, 117, 98, 12, 97, 103, 101, 110, 116, 12, 117, 115, 64, 116, 97, 108, 170,
1065 170, 115, 47, 108, 1, 57, 12, 108, 12, 59, 239, 191, 10,
1066 ],
1067 )
1068 .unwrap();
1069 let r = parse_manifest(&p).unwrap();
1070 assert!(r.manifest.entries.is_empty());
1071 assert!(r.warnings.iter().any(|w| w.contains("invalid owner/repo")));
1072 }
1073
1074 #[test]
1079 fn find_entry_in_found() {
1080 let e = Entry {
1081 entity_type: EntityType::Skill,
1082 name: "foo".into(),
1083 source: SourceFields::Local {
1084 path: "foo.md".into(),
1085 },
1086 };
1087 let m = Manifest {
1088 entries: vec![e.clone()],
1089 install_targets: vec![],
1090 };
1091 assert_eq!(find_entry_in("foo", &m).unwrap(), &e);
1092 }
1093
1094 #[test]
1095 fn find_entry_in_not_found() {
1096 let m = Manifest::default();
1097 assert!(find_entry_in("missing", &m).is_err());
1098 }
1099
1100 #[test]
1105 fn infer_name_from_md_path() {
1106 assert_eq!(infer_name("path/to/agent.md"), "agent");
1107 }
1108
1109 #[test]
1110 fn infer_name_from_dot() {
1111 assert_eq!(infer_name("."), "content");
1112 }
1113
1114 #[test]
1115 fn infer_name_from_url() {
1116 assert_eq!(infer_name("https://example.com/my-skill.md"), "my-skill");
1117 }
1118
1119 #[test]
1124 fn split_line_simple() {
1125 assert_eq!(
1126 split_line("github agent owner/repo agent.md"),
1127 vec!["github", "agent", "owner/repo", "agent.md"]
1128 );
1129 }
1130
1131 #[test]
1132 fn split_line_quoted() {
1133 assert_eq!(
1134 split_line("local skill \"my dir/foo.md\""),
1135 vec!["local", "skill", "my dir/foo.md"]
1136 );
1137 }
1138
1139 #[test]
1140 fn split_line_tabs() {
1141 assert_eq!(
1142 split_line("local\tskill\tfoo.md"),
1143 vec!["local", "skill", "foo.md"]
1144 );
1145 }
1146
1147 #[test]
1152 fn gitlab_entry_explicit_name_and_ref() {
1153 let dir = tempfile::tempdir().unwrap();
1154 let p = write_manifest(
1155 dir.path(),
1156 "gitlab skill my-skill my-group/my-project skills/my-skill.md v2.0",
1157 );
1158 let r = parse_manifest(&p).unwrap();
1159 assert_eq!(r.manifest.entries.len(), 1);
1160 let e = &r.manifest.entries[0];
1161 assert_eq!(e.source_type(), "gitlab");
1162 assert_eq!(e.entity_type, EntityType::Skill);
1163 assert_eq!(e.name, "my-skill");
1164 let (or, pir, ref_) = e.source.as_gitlab().unwrap();
1165 assert_eq!(or, "my-group/my-project");
1166 assert_eq!(pir, "skills/my-skill.md");
1167 assert_eq!(ref_, "v2.0");
1168 }
1169
1170 #[test]
1171 fn gitlab_entry_inferred_name_default_ref() {
1172 let dir = tempfile::tempdir().unwrap();
1173 let p = write_manifest(
1174 dir.path(),
1175 "gitlab agent my-group/my-project agents/reviewer.md",
1176 );
1177 let r = parse_manifest(&p).unwrap();
1178 assert_eq!(r.manifest.entries.len(), 1);
1179 let e = &r.manifest.entries[0];
1180 assert_eq!(e.source_type(), "gitlab");
1181 assert_eq!(e.name, "reviewer");
1182 let (or, pir, ref_) = e.source.as_gitlab().unwrap();
1183 assert_eq!(or, "my-group/my-project");
1184 assert_eq!(pir, "agents/reviewer.md");
1185 assert_eq!(ref_, "main");
1186 }
1187
1188 #[test]
1189 fn gitlab_entry_too_few_fields_warns() {
1190 let dir = tempfile::tempdir().unwrap();
1191 let p = write_manifest(dir.path(), "gitlab skill");
1192 let r = parse_manifest(&p).unwrap();
1193 assert!(r.manifest.entries.is_empty());
1194 assert!(r.warnings.iter().any(|w| w.contains("warning")));
1195 }
1196}