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>) {
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_local_entry(parts: &[String], entity_type: EntityType) -> (Option<Entry>, Vec<String>) {
185 let warnings = Vec::new();
186
187 let looks_like_path = Path::new(&parts[2])
192 .extension()
193 .is_some_and(|e| e.eq_ignore_ascii_case("md"))
194 || parts[2].contains('/');
195 if looks_like_path || parts.len() < 4 {
196 let local_path = &parts[2];
197 let name = infer_name(local_path);
198 (
199 Some(Entry {
200 entity_type,
201 name,
202 source: SourceFields::Local {
203 path: local_path.clone(),
204 },
205 }),
206 warnings,
207 )
208 } else {
209 let name = &parts[2];
210 let local_path = &parts[3];
211 (
212 Some(Entry {
213 entity_type,
214 name: name.clone(),
215 source: SourceFields::Local {
216 path: local_path.clone(),
217 },
218 }),
219 warnings,
220 )
221 }
222}
223
224fn parse_url_entry(
225 parts: &[String],
226 entity_type: EntityType,
227 lineno: usize,
228) -> (Option<Entry>, Vec<String>) {
229 let mut warnings = Vec::new();
230
231 if parts[2].starts_with("http") {
233 let url = &parts[2];
234 let name = infer_name(url);
235 (
236 Some(Entry {
237 entity_type,
238 name,
239 source: SourceFields::Url { url: url.clone() },
240 }),
241 warnings,
242 )
243 } else {
244 if parts.len() < 4 {
245 warnings.push(format!("warning: line {lineno}: url entry needs: name url"));
246 return (None, warnings);
247 }
248 let name = &parts[2];
249 let url = &parts[3];
250 (
251 Some(Entry {
252 entity_type,
253 name: name.clone(),
254 source: SourceFields::Url { url: url.clone() },
255 }),
256 warnings,
257 )
258 }
259}
260
261struct ParseAccumulator {
262 entries: Vec<Entry>,
263 install_targets: Vec<InstallTarget>,
264 warnings: Vec<String>,
265 seen_names: HashSet<String>,
266}
267
268fn parse_install_line(parts: &[String], lineno: usize, acc: &mut ParseAccumulator) {
269 if parts.len() < 3 {
270 acc.warnings.push(format!(
271 "warning: line {lineno}: install line needs: adapter scope"
272 ));
273 return;
274 }
275 let scope_str = &parts[2];
276 if let Some(scope) = Scope::parse(scope_str) {
277 acc.install_targets.push(InstallTarget {
278 adapter: parts[1].clone(),
279 scope,
280 });
281 } else {
282 let valid: Vec<&str> = Scope::ALL
283 .iter()
284 .map(super::models::Scope::as_str)
285 .collect();
286 acc.warnings.push(format!(
287 "warning: line {lineno}: invalid scope '{scope_str}', \
288 must be one of: {}",
289 valid.join(", ")
290 ));
291 }
292}
293
294fn validate_and_push_entry(entry: Entry, lineno: usize, acc: &mut ParseAccumulator) {
295 if !is_valid_name(&entry.name) {
296 acc.warnings.push(format!(
297 "warning: line {lineno}: invalid name '{}' \
298 — names must match [a-zA-Z0-9._-], skipping",
299 entry.name
300 ));
301 } else if acc.seen_names.contains(&entry.name) {
302 acc.warnings.push(format!(
303 "warning: line {lineno}: duplicate entry name '{}'",
304 entry.name
305 ));
306 acc.entries.push(entry);
307 } else {
308 acc.seen_names.insert(entry.name.clone());
309 acc.entries.push(entry);
310 }
311}
312
313fn parse_source_entry(
314 parts: &[String],
315 lineno: usize,
316 source_type: &str,
317) -> (Option<Entry>, Vec<String>) {
318 if parts.len() < 3 {
319 return (
320 None,
321 vec![format!("warning: line {lineno}: too few fields, skipping")],
322 );
323 }
324 let Some(entity_type) = EntityType::parse(&parts[1]) else {
325 return (
326 None,
327 vec![format!(
328 "warning: line {lineno}: unknown entity type '{}', skipping",
329 parts[1]
330 )],
331 );
332 };
333 match source_type {
334 "github" => parse_github_entry(parts, entity_type, lineno),
335 "local" => parse_local_entry(parts, entity_type),
336 "url" => parse_url_entry(parts, entity_type, lineno),
337 _ => (None, vec![]),
338 }
339}
340
341fn process_source_line(parts: &[String], lineno: usize, acc: &mut ParseAccumulator) {
342 let source_type = parts[0].as_str();
343 let (entry_opt, mut entry_warnings) = parse_source_entry(parts, lineno, source_type);
344 acc.warnings.append(&mut entry_warnings);
345 if let Some(entry) = entry_opt {
346 validate_and_push_entry(entry, lineno, acc);
347 }
348}
349
350pub fn parse_manifest(manifest_path: &Path) -> Result<ParseResult, SkillfileError> {
351 let raw_bytes = std::fs::read(manifest_path)?;
352
353 let text = if raw_bytes.starts_with(&[0xEF, 0xBB, 0xBF]) {
355 String::from_utf8_lossy(&raw_bytes[3..]).into_owned()
356 } else {
357 String::from_utf8_lossy(&raw_bytes).into_owned()
358 };
359
360 let mut acc = ParseAccumulator {
361 entries: Vec::new(),
362 install_targets: Vec::new(),
363 warnings: Vec::new(),
364 seen_names: HashSet::new(),
365 };
366
367 for (lineno, raw) in text.lines().enumerate() {
368 let lineno = lineno + 1; let line = raw.trim();
370 if line.is_empty() || line.starts_with('#') {
371 continue;
372 }
373
374 let parts = strip_inline_comment(split_line(line));
375 if parts.len() < 2 {
376 acc.warnings
377 .push(format!("warning: line {lineno}: too few fields, skipping"));
378 continue;
379 }
380
381 match parts[0].as_str() {
382 "install" => parse_install_line(&parts, lineno, &mut acc),
383 _ if KNOWN_SOURCES.contains(&parts[0].as_str()) => {
384 process_source_line(&parts, lineno, &mut acc);
385 }
386 st => {
387 acc.warnings.push(format!(
388 "warning: line {lineno}: unknown source type '{st}', skipping"
389 ));
390 }
391 }
392 }
393
394 Ok(ParseResult {
395 manifest: Manifest {
396 entries: acc.entries,
397 install_targets: acc.install_targets,
398 },
399 warnings: acc.warnings,
400 })
401}
402
403#[must_use]
404pub fn parse_manifest_line(line: &str) -> Option<Entry> {
405 let parts = split_line(line);
406 let parts = strip_inline_comment(parts);
407 if parts.len() < 3 {
408 return None;
409 }
410 let source_type = parts[0].as_str();
411 if !KNOWN_SOURCES.contains(&source_type) || source_type == "install" {
412 return None;
413 }
414 let entity_type = EntityType::parse(&parts[1])?;
415 let (entry_opt, _) = match source_type {
416 "github" => parse_github_entry(&parts, entity_type, 0),
417 "local" => parse_local_entry(&parts, entity_type),
418 "url" => parse_url_entry(&parts, entity_type, 0),
419 _ => return None,
420 };
421 entry_opt
422}
423
424pub fn find_entry_in<'a>(name: &str, manifest: &'a Manifest) -> Result<&'a Entry, SkillfileError> {
425 manifest
426 .entries
427 .iter()
428 .find(|e| e.name == name)
429 .ok_or_else(|| {
430 SkillfileError::Manifest(format!("no entry named '{name}' in {MANIFEST_NAME}"))
431 })
432}
433
434#[cfg(test)]
435mod tests {
436 use super::*;
437 use std::fs;
438
439 fn dedent_line(line: &str, indent: usize) -> &str {
440 if line.len() >= indent {
441 &line[indent..]
442 } else {
443 line.trim()
444 }
445 }
446
447 fn write_manifest(dir: &Path, content: &str) -> std::path::PathBuf {
448 let p = dir.join(MANIFEST_NAME);
449 let lines: Vec<&str> = content.lines().collect();
451 let min_indent = lines
452 .iter()
453 .filter(|l| !l.trim().is_empty())
454 .map(|l| l.len() - l.trim_start().len())
455 .min()
456 .unwrap_or(0);
457 let dedented: String = lines
458 .iter()
459 .map(|l| dedent_line(l, min_indent))
460 .collect::<Vec<_>>()
461 .join("\n");
462 fs::write(&p, dedented.trim_start_matches('\n').to_string() + "\n").unwrap();
463 p
464 }
465
466 #[test]
471 fn github_entry_explicit_name_and_ref() {
472 let dir = tempfile::tempdir().unwrap();
473 let p = write_manifest(
474 dir.path(),
475 "github agent backend-dev owner/repo path/to/agent.md main",
476 );
477 let r = parse_manifest(&p).unwrap();
478 assert_eq!(r.manifest.entries.len(), 1);
479 let e = &r.manifest.entries[0];
480 assert_eq!(e.source_type(), "github");
481 assert_eq!(e.entity_type, EntityType::Agent);
482 assert_eq!(e.name, "backend-dev");
483 assert_eq!(e.owner_repo(), "owner/repo");
484 assert_eq!(e.path_in_repo(), "path/to/agent.md");
485 assert_eq!(e.ref_(), "main");
486 }
487
488 #[test]
489 fn local_entry_bare_dir_name() {
490 let dir = tempfile::tempdir().unwrap();
491 let p = write_manifest(dir.path(), "local skill bash-craftsman");
492 let r = parse_manifest(&p).unwrap();
493 assert!(
494 r.warnings.is_empty(),
495 "unexpected warnings: {:?}",
496 r.warnings
497 );
498 assert_eq!(r.manifest.entries.len(), 1);
499 let e = &r.manifest.entries[0];
500 assert_eq!(e.source_type(), "local");
501 assert_eq!(e.entity_type, EntityType::Skill);
502 assert_eq!(e.name, "bash-craftsman");
503 assert_eq!(e.local_path(), "bash-craftsman");
504 }
505
506 #[test]
507 fn local_entry_explicit_name() {
508 let dir = tempfile::tempdir().unwrap();
509 let p = write_manifest(dir.path(), "local skill git-commit skills/git/commit.md");
510 let r = parse_manifest(&p).unwrap();
511 assert_eq!(r.manifest.entries.len(), 1);
512 let e = &r.manifest.entries[0];
513 assert_eq!(e.source_type(), "local");
514 assert_eq!(e.entity_type, EntityType::Skill);
515 assert_eq!(e.name, "git-commit");
516 assert_eq!(e.local_path(), "skills/git/commit.md");
517 }
518
519 #[test]
520 fn url_entry_explicit_name() {
521 let dir = tempfile::tempdir().unwrap();
522 let p = write_manifest(
523 dir.path(),
524 "url skill my-skill https://example.com/skill.md",
525 );
526 let r = parse_manifest(&p).unwrap();
527 assert_eq!(r.manifest.entries.len(), 1);
528 let e = &r.manifest.entries[0];
529 assert_eq!(e.source_type(), "url");
530 assert_eq!(e.name, "my-skill");
531 assert_eq!(e.url(), "https://example.com/skill.md");
532 }
533
534 #[test]
539 fn github_entry_inferred_name() {
540 let dir = tempfile::tempdir().unwrap();
541 let p = write_manifest(
542 dir.path(),
543 "github agent owner/repo path/to/agent.md main",
544 );
545 let r = parse_manifest(&p).unwrap();
546 assert_eq!(r.manifest.entries.len(), 1);
547 let e = &r.manifest.entries[0];
548 assert_eq!(e.name, "agent");
549 assert_eq!(e.owner_repo(), "owner/repo");
550 assert_eq!(e.path_in_repo(), "path/to/agent.md");
551 assert_eq!(e.ref_(), "main");
552 }
553
554 #[test]
555 fn local_entry_inferred_name_from_path() {
556 let dir = tempfile::tempdir().unwrap();
557 let p = write_manifest(dir.path(), "local skill skills/git/commit.md");
558 let r = parse_manifest(&p).unwrap();
559 assert_eq!(r.manifest.entries.len(), 1);
560 let e = &r.manifest.entries[0];
561 assert_eq!(e.name, "commit");
562 assert_eq!(e.local_path(), "skills/git/commit.md");
563 }
564
565 #[test]
566 fn local_entry_inferred_name_from_md_extension() {
567 let dir = tempfile::tempdir().unwrap();
568 let p = write_manifest(dir.path(), "local skill commit.md");
569 let r = parse_manifest(&p).unwrap();
570 assert_eq!(r.manifest.entries.len(), 1);
571 assert_eq!(r.manifest.entries[0].name, "commit");
572 }
573
574 #[test]
575 fn url_entry_inferred_name() {
576 let dir = tempfile::tempdir().unwrap();
577 let p = write_manifest(dir.path(), "url skill https://example.com/my-skill.md");
578 let r = parse_manifest(&p).unwrap();
579 assert_eq!(r.manifest.entries.len(), 1);
580 let e = &r.manifest.entries[0];
581 assert_eq!(e.name, "my-skill");
582 assert_eq!(e.url(), "https://example.com/my-skill.md");
583 }
584
585 #[test]
590 fn github_entry_inferred_name_default_ref() {
591 let dir = tempfile::tempdir().unwrap();
592 let p = write_manifest(dir.path(), "github agent owner/repo path/to/agent.md");
593 let r = parse_manifest(&p).unwrap();
594 assert_eq!(r.manifest.entries[0].ref_(), "main");
595 }
596
597 #[test]
598 fn github_entry_explicit_name_default_ref() {
599 let dir = tempfile::tempdir().unwrap();
600 let p = write_manifest(
601 dir.path(),
602 "github agent my-agent owner/repo path/to/agent.md",
603 );
604 let r = parse_manifest(&p).unwrap();
605 assert_eq!(r.manifest.entries[0].ref_(), "main");
606 }
607
608 #[test]
613 fn github_entry_at_ref_inferred_name() {
614 let dir = tempfile::tempdir().unwrap();
615 let p = write_manifest(dir.path(), "github skill nuxt/ui@v4 path/to/SKILL.md");
616 let r = parse_manifest(&p).unwrap();
617 assert_eq!(r.manifest.entries.len(), 1);
618 let e = &r.manifest.entries[0];
619 assert_eq!(e.name, "SKILL");
620 assert_eq!(e.owner_repo(), "nuxt/ui");
621 assert_eq!(e.ref_(), "v4");
622 }
623
624 #[test]
625 fn github_entry_at_ref_explicit_name() {
626 let dir = tempfile::tempdir().unwrap();
627 let p = write_manifest(
628 dir.path(),
629 "github skill my-skill nuxt/ui@v4 path/to/SKILL.md",
630 );
631 let r = parse_manifest(&p).unwrap();
632 assert_eq!(r.manifest.entries.len(), 1);
633 let e = &r.manifest.entries[0];
634 assert_eq!(e.name, "my-skill");
635 assert_eq!(e.owner_repo(), "nuxt/ui");
636 assert_eq!(e.ref_(), "v4");
637 }
638
639 #[test]
640 fn github_entry_at_ref_with_main() {
641 let dir = tempfile::tempdir().unwrap();
642 let p = write_manifest(
643 dir.path(),
644 "github skill owner/repo@main path/to/SKILL.md",
645 );
646 let r = parse_manifest(&p).unwrap();
647 assert_eq!(r.manifest.entries[0].owner_repo(), "owner/repo");
648 assert_eq!(r.manifest.entries[0].ref_(), "main");
649 }
650
651 #[test]
652 fn github_entry_at_ref_with_sha() {
653 let dir = tempfile::tempdir().unwrap();
654 let p = write_manifest(
655 dir.path(),
656 "github skill owner/repo@abc123def456 path/to/SKILL.md",
657 );
658 let r = parse_manifest(&p).unwrap();
659 assert_eq!(r.manifest.entries[0].owner_repo(), "owner/repo");
660 assert_eq!(r.manifest.entries[0].ref_(), "abc123def456");
661 }
662
663 #[test]
664 fn github_entry_at_ref_takes_priority_over_positional() {
665 let dir = tempfile::tempdir().unwrap();
666 let p = write_manifest(
667 dir.path(),
668 "github skill nuxt/ui@v4 path/to/SKILL.md v3",
669 );
670 let r = parse_manifest(&p).unwrap();
671 let e = &r.manifest.entries[0];
672 assert_eq!(e.owner_repo(), "nuxt/ui");
673 assert_eq!(e.ref_(), "v4");
674 }
675
676 #[test]
677 fn github_entry_at_ref_requires_owner_repo_before_ref_separator() {
678 let dir = tempfile::tempdir().unwrap();
679 let p = write_manifest(dir.path(), "github skill us@tal/repo path/to/SKILL.md");
680 let r = parse_manifest(&p).unwrap();
681 assert!(r.manifest.entries.is_empty());
682 assert!(r
683 .warnings
684 .iter()
685 .any(|warning| warning.contains("invalid owner/repo 'us@tal/repo'")));
686 }
687
688 #[test]
689 fn github_entry_at_ref_requires_owner_repo_before_ref_separator_with_name() {
690 let dir = tempfile::tempdir().unwrap();
691 let p = write_manifest(
692 dir.path(),
693 "github skill my-skill us@tal/repo path/to/SKILL.md",
694 );
695 let r = parse_manifest(&p).unwrap();
696 assert!(r.manifest.entries.is_empty());
697 assert!(r
698 .warnings
699 .iter()
700 .any(|warning| warning.contains("invalid owner/repo 'us@tal/repo'")));
701 }
702
703 #[test]
708 fn install_target_parsed() {
709 let dir = tempfile::tempdir().unwrap();
710 let p = write_manifest(dir.path(), "install claude-code global");
711 let r = parse_manifest(&p).unwrap();
712 assert_eq!(r.manifest.install_targets.len(), 1);
713 let t = &r.manifest.install_targets[0];
714 assert_eq!(t.adapter, "claude-code");
715 assert_eq!(t.scope, Scope::Global);
716 }
717
718 #[test]
719 fn multiple_install_targets() {
720 let dir = tempfile::tempdir().unwrap();
721 let p = write_manifest(
722 dir.path(),
723 "install claude-code global\ninstall claude-code local",
724 );
725 let r = parse_manifest(&p).unwrap();
726 assert_eq!(r.manifest.install_targets.len(), 2);
727 assert_eq!(r.manifest.install_targets[0].scope, Scope::Global);
728 assert_eq!(r.manifest.install_targets[1].scope, Scope::Local);
729 }
730
731 #[test]
732 fn install_targets_not_in_entries() {
733 let dir = tempfile::tempdir().unwrap();
734 let p = write_manifest(
735 dir.path(),
736 "install claude-code global\ngithub agent owner/repo path/to/agent.md",
737 );
738 let r = parse_manifest(&p).unwrap();
739 assert_eq!(r.manifest.entries.len(), 1);
740 assert_eq!(r.manifest.install_targets.len(), 1);
741 }
742
743 #[test]
748 fn comments_and_blanks_skipped() {
749 let dir = tempfile::tempdir().unwrap();
750 let p = write_manifest(
751 dir.path(),
752 "# this is a comment\n\n# another comment\nlocal skill foo skills/foo.md",
753 );
754 let r = parse_manifest(&p).unwrap();
755 assert_eq!(r.manifest.entries.len(), 1);
756 }
757
758 #[test]
759 fn malformed_too_few_fields() {
760 let dir = tempfile::tempdir().unwrap();
761 let p = write_manifest(dir.path(), "github agent");
762 let r = parse_manifest(&p).unwrap();
763 assert!(r.manifest.entries.is_empty());
764 assert!(r.warnings.iter().any(|w| w.contains("warning")));
765 }
766
767 #[test]
768 fn unknown_source_type_skipped() {
769 let dir = tempfile::tempdir().unwrap();
770 let p = write_manifest(dir.path(), "svn skill foo some/path");
771 let r = parse_manifest(&p).unwrap();
772 assert!(r.manifest.entries.is_empty());
773 assert!(r.warnings.iter().any(|w| w.contains("warning")));
774 assert!(r.warnings.iter().any(|w| w.contains("svn")));
775 }
776
777 #[test]
782 fn inline_comment_stripped() {
783 let dir = tempfile::tempdir().unwrap();
784 let p = write_manifest(
785 dir.path(),
786 "github agent owner/repo agents/foo.md # my note",
787 );
788 let r = parse_manifest(&p).unwrap();
789 assert_eq!(r.manifest.entries.len(), 1);
790 let e = &r.manifest.entries[0];
791 assert_eq!(e.ref_(), "main"); assert_eq!(e.name, "foo");
793 }
794
795 #[test]
796 fn inline_comment_on_install_line() {
797 let dir = tempfile::tempdir().unwrap();
798 let p = write_manifest(dir.path(), "install claude-code global # primary target");
799 let r = parse_manifest(&p).unwrap();
800 assert_eq!(r.manifest.install_targets.len(), 1);
801 assert_eq!(r.manifest.install_targets[0].scope, Scope::Global);
802 }
803
804 #[test]
805 fn inline_comment_after_ref() {
806 let dir = tempfile::tempdir().unwrap();
807 let p = write_manifest(
808 dir.path(),
809 "github agent my-agent owner/repo agents/foo.md v1.0 # pinned version",
810 );
811 let r = parse_manifest(&p).unwrap();
812 assert_eq!(r.manifest.entries[0].ref_(), "v1.0");
813 }
814
815 #[test]
820 fn quoted_path_with_spaces() {
821 let dir = tempfile::tempdir().unwrap();
822 let p = dir.path().join(MANIFEST_NAME);
823 fs::write(&p, "local skill my-skill \"skills/my dir/foo.md\"\n").unwrap();
824 let r = parse_manifest(&p).unwrap();
825 assert_eq!(r.manifest.entries.len(), 1);
826 assert_eq!(r.manifest.entries[0].local_path(), "skills/my dir/foo.md");
827 }
828
829 #[test]
830 fn quoted_github_path() {
831 let dir = tempfile::tempdir().unwrap();
832 let p = dir.path().join(MANIFEST_NAME);
833 fs::write(
834 &p,
835 "github skill owner/repo \"path with spaces/skill.md\"\n",
836 )
837 .unwrap();
838 let r = parse_manifest(&p).unwrap();
839 assert_eq!(r.manifest.entries.len(), 1);
840 assert_eq!(
841 r.manifest.entries[0].path_in_repo(),
842 "path with spaces/skill.md"
843 );
844 }
845
846 #[test]
847 fn mixed_quoted_and_unquoted() {
848 let dir = tempfile::tempdir().unwrap();
849 let p = dir.path().join(MANIFEST_NAME);
850 fs::write(
851 &p,
852 "github agent my-agent owner/repo \"agents/path with spaces/foo.md\"\n",
853 )
854 .unwrap();
855 let r = parse_manifest(&p).unwrap();
856 assert_eq!(r.manifest.entries.len(), 1);
857 assert_eq!(r.manifest.entries[0].name, "my-agent");
858 assert_eq!(
859 r.manifest.entries[0].path_in_repo(),
860 "agents/path with spaces/foo.md"
861 );
862 }
863
864 #[test]
865 fn unquoted_fields_parse_identically() {
866 let dir = tempfile::tempdir().unwrap();
867 let p = write_manifest(
868 dir.path(),
869 "github agent backend-dev owner/repo path/to/agent.md main",
870 );
871 let r = parse_manifest(&p).unwrap();
872 assert_eq!(r.manifest.entries[0].name, "backend-dev");
873 assert_eq!(r.manifest.entries[0].ref_(), "main");
874 }
875
876 #[test]
881 fn valid_entry_name_accepted() {
882 let dir = tempfile::tempdir().unwrap();
883 let p = write_manifest(dir.path(), "local skill my-skill_v2.0 skills/foo.md");
884 let r = parse_manifest(&p).unwrap();
885 assert_eq!(r.manifest.entries.len(), 1);
886 assert_eq!(r.manifest.entries[0].name, "my-skill_v2.0");
887 }
888
889 #[test]
890 fn invalid_entry_name_rejected() {
891 let dir = tempfile::tempdir().unwrap();
892 let p = dir.path().join(MANIFEST_NAME);
893 fs::write(&p, "local skill \"my skill!\" skills/foo.md\n").unwrap();
894 let r = parse_manifest(&p).unwrap();
895 assert!(r.manifest.entries.is_empty());
896 assert!(r
897 .warnings
898 .iter()
899 .any(|w| w.to_lowercase().contains("invalid name")
900 || w.to_lowercase().contains("warning")));
901 }
902
903 #[test]
904 fn inferred_name_validated() {
905 let dir = tempfile::tempdir().unwrap();
906 let p = write_manifest(dir.path(), "local skill skills/foo.md");
907 let r = parse_manifest(&p).unwrap();
908 assert_eq!(r.manifest.entries.len(), 1);
909 assert_eq!(r.manifest.entries[0].name, "foo");
910 }
911
912 #[test]
917 fn valid_scope_accepted() {
918 for (scope_str, expected) in &[("global", Scope::Global), ("local", Scope::Local)] {
919 let dir = tempfile::tempdir().unwrap();
920 let p = write_manifest(dir.path(), &format!("install claude-code {scope_str}"));
921 let r = parse_manifest(&p).unwrap();
922 assert_eq!(r.manifest.install_targets.len(), 1);
923 assert_eq!(r.manifest.install_targets[0].scope, *expected);
924 }
925 }
926
927 #[test]
928 fn invalid_scope_rejected() {
929 let dir = tempfile::tempdir().unwrap();
930 let p = write_manifest(dir.path(), "install claude-code worldwide");
931 let r = parse_manifest(&p).unwrap();
932 assert!(r.manifest.install_targets.is_empty());
933 assert!(r
934 .warnings
935 .iter()
936 .any(|w| w.to_lowercase().contains("scope") || w.to_lowercase().contains("warning")));
937 }
938
939 #[test]
944 fn duplicate_entry_name_warns() {
945 let dir = tempfile::tempdir().unwrap();
946 let p = write_manifest(
947 dir.path(),
948 "local skill foo skills/foo.md\nlocal agent foo agents/foo.md",
949 );
950 let r = parse_manifest(&p).unwrap();
951 assert_eq!(r.manifest.entries.len(), 2); assert!(r
953 .warnings
954 .iter()
955 .any(|w| w.to_lowercase().contains("duplicate")));
956 }
957
958 #[test]
963 fn utf8_bom_handled() {
964 let dir = tempfile::tempdir().unwrap();
965 let p = dir.path().join(MANIFEST_NAME);
966 let mut content = vec![0xEF, 0xBB, 0xBF]; content.extend_from_slice(b"install claude-code global\n");
968 fs::write(&p, content).unwrap();
969 let r = parse_manifest(&p).unwrap();
970 assert_eq!(r.manifest.install_targets.len(), 1);
971 assert_eq!(
972 r.manifest.install_targets[0],
973 InstallTarget {
974 adapter: "claude-code".into(),
975 scope: Scope::Global,
976 }
977 );
978 }
979
980 #[test]
985 fn unknown_entity_type_skipped_with_warning() {
986 let dir = tempfile::tempdir().unwrap();
987 let p = write_manifest(dir.path(), "local hook foo hooks/foo.md");
988 let r = parse_manifest(&p).unwrap();
989 assert!(r.manifest.entries.is_empty());
990 assert!(r.warnings.iter().any(|w| w.contains("unknown entity type")));
991 }
992
993 #[test]
994 fn github_invalid_owner_repo_skipped_with_warning() {
995 let dir = tempfile::tempdir().unwrap();
996 let p = write_manifest(dir.path(), "github skill my-skill noslash path.md");
998 let r = parse_manifest(&p).unwrap();
999 assert!(
1000 r.manifest.entries.is_empty(),
1001 "entry with invalid owner/repo should be skipped"
1002 );
1003 assert!(r.warnings.iter().any(|w| w.contains("owner/repo")));
1004 }
1005
1006 #[test]
1007 fn github_invalid_owner_repo_after_lossy_utf8_decode_skipped_with_warning() {
1008 let dir = tempfile::tempdir().unwrap();
1009 let p = dir.path().join(MANIFEST_NAME);
1010 fs::write(
1011 &p,
1012 [
1013 240, 174, 174, 174, 240, 174, 174, 170, 240, 105, 116, 104, 117, 97, 10, 103, 105,
1014 116, 104, 117, 98, 12, 97, 103, 101, 110, 116, 12, 117, 115, 64, 116, 97, 108, 170,
1015 170, 115, 47, 108, 1, 57, 12, 108, 12, 59, 239, 191, 10,
1016 ],
1017 )
1018 .unwrap();
1019 let r = parse_manifest(&p).unwrap();
1020 assert!(r.manifest.entries.is_empty());
1021 assert!(r.warnings.iter().any(|w| w.contains("invalid owner/repo")));
1022 }
1023
1024 #[test]
1029 fn find_entry_in_found() {
1030 let e = Entry {
1031 entity_type: EntityType::Skill,
1032 name: "foo".into(),
1033 source: SourceFields::Local {
1034 path: "foo.md".into(),
1035 },
1036 };
1037 let m = Manifest {
1038 entries: vec![e.clone()],
1039 install_targets: vec![],
1040 };
1041 assert_eq!(find_entry_in("foo", &m).unwrap(), &e);
1042 }
1043
1044 #[test]
1045 fn find_entry_in_not_found() {
1046 let m = Manifest::default();
1047 assert!(find_entry_in("missing", &m).is_err());
1048 }
1049
1050 #[test]
1055 fn infer_name_from_md_path() {
1056 assert_eq!(infer_name("path/to/agent.md"), "agent");
1057 }
1058
1059 #[test]
1060 fn infer_name_from_dot() {
1061 assert_eq!(infer_name("."), "content");
1062 }
1063
1064 #[test]
1065 fn infer_name_from_url() {
1066 assert_eq!(infer_name("https://example.com/my-skill.md"), "my-skill");
1067 }
1068
1069 #[test]
1074 fn split_line_simple() {
1075 assert_eq!(
1076 split_line("github agent owner/repo agent.md"),
1077 vec!["github", "agent", "owner/repo", "agent.md"]
1078 );
1079 }
1080
1081 #[test]
1082 fn split_line_quoted() {
1083 assert_eq!(
1084 split_line("local skill \"my dir/foo.md\""),
1085 vec!["local", "skill", "my dir/foo.md"]
1086 );
1087 }
1088
1089 #[test]
1090 fn split_line_tabs() {
1091 assert_eq!(
1092 split_line("local\tskill\tfoo.md"),
1093 vec!["local", "skill", "foo.md"]
1094 );
1095 }
1096}