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