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
43pub fn split_line(line: &str) -> Option<Vec<String>> {
48 shlex::split(line)
49}
50
51pub fn strip_inline_comment(line: &str) -> &str {
52 let mut quote = None;
53 let mut escaped = false;
54 let mut previous_was_whitespace = false;
55
56 for (index, ch) in line.char_indices() {
57 if escaped {
58 escaped = false;
59 previous_was_whitespace = false;
60 continue;
61 }
62 match quote {
63 Some(active) if ch == active => quote = None,
64 Some('"') | None if ch == '\\' => escaped = true,
65 None if ch == '\'' || ch == '"' => quote = Some(ch),
66 None if ch == '#' && previous_was_whitespace => {
67 return line[..index].trim_end();
68 }
69 Some(_) | None => {}
70 }
71 previous_was_whitespace = quote.is_none() && ch.is_whitespace();
72 }
73
74 line
75}
76
77#[must_use]
79pub fn quote_field(field: &str) -> String {
80 match shlex::try_quote(field) {
81 Ok(quoted) => quoted.into_owned(),
82 Err(_) => field.to_string(),
83 }
84}
85
86#[must_use]
93pub fn parse_owner_repo_ref(input: &str) -> (String, Option<String>) {
94 match input.split_once('@') {
95 Some((repo, ref_)) if !repo.is_empty() && !ref_.is_empty() => {
96 (repo.to_string(), Some(ref_.to_string()))
97 }
98 _ => (input.to_string(), None),
99 }
100}
101
102#[must_use]
106pub fn resolve_explicit_owner_repo_ref(
107 at_ref: Option<String>,
108 positional_ref: Option<&str>,
109) -> Option<String> {
110 at_ref.filter(|ref_| !ref_.trim().is_empty()).or_else(|| {
111 positional_ref
112 .filter(|ref_| !ref_.trim().is_empty())
113 .map(String::from)
114 })
115}
116
117#[must_use]
121pub fn resolve_owner_repo_ref(at_ref: Option<String>, positional_ref: Option<&str>) -> String {
122 resolve_explicit_owner_repo_ref(at_ref, positional_ref)
123 .unwrap_or_else(|| DEFAULT_REF.to_string())
124}
125
126fn parse_github_owner_repo(
127 raw_owner_repo: &str,
128 lineno: usize,
129 warnings: &mut Vec<String>,
130) -> Option<(String, Option<String>)> {
131 let (owner_repo, at_ref) = parse_owner_repo_ref(raw_owner_repo);
132 if owner_repo.contains('/') {
133 return Some((owner_repo, at_ref));
134 }
135 warnings.push(format!(
136 "warning: line {lineno}: invalid owner/repo '{raw_owner_repo}' \
137 — expected 'owner/repo' or 'owner/repo@ref' format"
138 ));
139 None
140}
141
142fn validate_repo_path(path: &str, source_type: &str, lineno: usize) -> Result<(), String> {
143 let path = path.trim();
144 if path == "." || Path::new(path).file_name().is_some() {
145 return Ok(());
146 }
147 Err(format!(
148 "warning: line {lineno}: {source_type} entry has invalid repository path '{path}' \
149 — expected a non-root path or '.'"
150 ))
151}
152
153fn parse_github_entry(
155 parts: &[String],
156 entity_type: EntityType,
157 lineno: usize,
158) -> (Option<Entry>, Vec<String>) {
159 let mut warnings = Vec::new();
160
161 let (name, owner_repo, path_in_repo, ref_) = if parts[2].contains('/') {
163 if parts.len() < 4 {
164 warnings.push(format!(
165 "warning: line {lineno}: github entry needs at least: owner/repo path"
166 ));
167 return (None, warnings);
168 }
169 let Some((parsed_repo, parsed_ref)) =
170 parse_github_owner_repo(&parts[2], lineno, &mut warnings)
171 else {
172 return (None, warnings);
173 };
174 let ref_ = resolve_owner_repo_ref(parsed_ref, parts.get(4).map(String::as_str));
175 (infer_name(&parts[3]), parsed_repo, &parts[3], ref_)
176 } else {
177 if parts.len() < 5 {
178 warnings.push(format!(
179 "warning: line {lineno}: github entry needs at least: name owner/repo path"
180 ));
181 return (None, warnings);
182 }
183 let Some((parsed_repo, parsed_ref)) =
184 parse_github_owner_repo(&parts[3], lineno, &mut warnings)
185 else {
186 return (None, warnings);
187 };
188 let ref_ = resolve_owner_repo_ref(parsed_ref, parts.get(5).map(String::as_str));
189 (parts[2].clone(), parsed_repo, &parts[4], ref_)
190 };
191
192 if let Err(warning) = validate_repo_path(path_in_repo, "github", lineno) {
193 warnings.push(warning);
194 return (None, warnings);
195 }
196
197 let entry = Entry {
198 entity_type,
199 name,
200 source: SourceFields::Github {
201 owner_repo,
202 path_in_repo: path_in_repo.clone(),
203 ref_,
204 },
205 };
206 (Some(entry), warnings)
207}
208
209fn parse_gitlab_entry(
211 parts: &[String],
212 entity_type: EntityType,
213 lineno: usize,
214) -> (Option<Entry>, Vec<String>) {
215 let mut warnings = Vec::new();
216
217 let (name, owner_repo, path_in_repo, ref_) = if parts[2].contains('/') {
218 if parts.len() < 4 {
219 warnings.push(format!(
220 "warning: line {lineno}: gitlab entry needs at least: owner/repo path"
221 ));
222 return (None, warnings);
223 }
224 let ref_ = resolve_owner_repo_ref(None, parts.get(4).map(String::as_str));
225 (infer_name(&parts[3]), &parts[2], &parts[3], ref_)
226 } else {
227 if parts.len() < 5 {
228 warnings.push(format!(
229 "warning: line {lineno}: gitlab entry needs at least: name owner/repo path"
230 ));
231 return (None, warnings);
232 }
233 if !parts[3].contains('/') {
234 warnings.push(format!(
235 "warning: line {lineno}: invalid owner/repo '{}' \
236 — expected 'owner/repo' format",
237 parts[3],
238 ));
239 return (None, warnings);
240 }
241 let ref_ = resolve_owner_repo_ref(None, parts.get(5).map(String::as_str));
242 (parts[2].clone(), &parts[3], &parts[4], ref_)
243 };
244
245 if let Err(warning) = validate_repo_path(path_in_repo, "gitlab", lineno) {
246 warnings.push(warning);
247 return (None, warnings);
248 }
249
250 let entry = Entry {
251 entity_type,
252 name,
253 source: SourceFields::Gitlab {
254 owner_repo: owner_repo.clone(),
255 path_in_repo: path_in_repo.clone(),
256 ref_,
257 },
258 };
259 (Some(entry), warnings)
260}
261
262fn parse_local_entry(
263 parts: &[String],
264 entity_type: EntityType,
265 lineno: usize,
266) -> (Option<Entry>, Vec<String>) {
267 let looks_like_path = Path::new(&parts[2])
272 .extension()
273 .is_some_and(|e| e.eq_ignore_ascii_case("md"))
274 || parts[2].contains('/');
275 let (name, local_path) = if looks_like_path || parts.len() < 4 {
276 (infer_name(&parts[2]), &parts[2])
277 } else {
278 (parts[2].clone(), &parts[3])
279 };
280 if local_path.trim().is_empty() {
281 return (
282 None,
283 vec![format!(
284 "warning: line {lineno}: local entry path must not be empty"
285 )],
286 );
287 }
288 (
289 Some(Entry {
290 entity_type,
291 name,
292 source: SourceFields::Local {
293 path: local_path.clone(),
294 },
295 }),
296 Vec::new(),
297 )
298}
299
300fn parse_url_entry(
301 parts: &[String],
302 entity_type: EntityType,
303 lineno: usize,
304) -> (Option<Entry>, Vec<String>) {
305 let mut warnings = Vec::new();
306
307 if parts[2].starts_with("http") {
309 let url = &parts[2];
310 let name = infer_name(url);
311 (
312 Some(Entry {
313 entity_type,
314 name,
315 source: SourceFields::Url { url: url.clone() },
316 }),
317 warnings,
318 )
319 } else {
320 if parts.len() < 4 {
321 warnings.push(format!("warning: line {lineno}: url entry needs: name url"));
322 return (None, warnings);
323 }
324 let name = &parts[2];
325 let url = &parts[3];
326 if url.trim().is_empty() {
327 warnings.push(format!(
328 "warning: line {lineno}: url entry URL must not be empty"
329 ));
330 return (None, warnings);
331 }
332 (
333 Some(Entry {
334 entity_type,
335 name: name.clone(),
336 source: SourceFields::Url { url: url.clone() },
337 }),
338 warnings,
339 )
340 }
341}
342
343struct ParseAccumulator {
344 entries: Vec<Entry>,
345 install_targets: Vec<InstallTarget>,
346 warnings: Vec<String>,
347 seen_names: HashSet<String>,
348}
349
350fn parse_install_line(parts: &[String], lineno: usize, acc: &mut ParseAccumulator) {
351 if parts.len() < 3 {
352 acc.warnings.push(format!(
353 "warning: line {lineno}: install line needs: adapter scope"
354 ));
355 return;
356 }
357 let adapter = &parts[1];
358 if adapter.trim().is_empty() {
359 acc.warnings.push(format!(
360 "warning: line {lineno}: install adapter must not be empty"
361 ));
362 return;
363 }
364 let scope_str = &parts[2];
365 if let Some(scope) = Scope::parse(scope_str) {
366 acc.install_targets
367 .push(InstallTarget::platform(adapter.clone(), scope));
368 } else {
369 let valid: Vec<&str> = Scope::ALL
370 .iter()
371 .map(super::models::Scope::as_str)
372 .collect();
373 acc.warnings.push(format!(
374 "warning: line {lineno}: invalid scope '{scope_str}', \
375 must be one of: {}",
376 valid.join(", ")
377 ));
378 }
379}
380
381fn parse_install_path_line(parts: &[String], lineno: usize, acc: &mut ParseAccumulator) {
382 if parts.len() != 4 {
383 acc.warnings.push(format!(
384 "warning: line {lineno}: install-path line needs exactly: tool-name entity-type path"
385 ));
386 return;
387 }
388
389 let tool_name = &parts[1];
390 if tool_name.trim().is_empty() {
391 acc.warnings.push(format!(
392 "warning: line {lineno}: install-path tool-name must not be empty"
393 ));
394 return;
395 }
396
397 let entity_type_str = &parts[2];
398 let Some(entity_type) = EntityType::parse(entity_type_str) else {
399 let valid: Vec<&str> = EntityType::ALL
400 .iter()
401 .map(super::models::EntityType::as_str)
402 .collect();
403 acc.warnings.push(format!(
404 "warning: line {lineno}: invalid entity type '{entity_type_str}', \
405 must be one of: {}",
406 valid.join(", ")
407 ));
408 return;
409 };
410
411 let path = &parts[3];
412 if path.trim().is_empty() {
413 acc.warnings.push(format!(
414 "warning: line {lineno}: install-path path must not be empty"
415 ));
416 return;
417 }
418
419 acc.install_targets.push(InstallTarget::path(
420 tool_name.clone(),
421 entity_type,
422 path.clone(),
423 ));
424}
425
426fn validate_and_push_entry(entry: Entry, lineno: usize, acc: &mut ParseAccumulator) {
427 if !is_valid_name(&entry.name) {
428 acc.warnings.push(format!(
429 "warning: line {lineno}: invalid name '{}' \
430 — names must match [a-zA-Z0-9._-], skipping",
431 entry.name
432 ));
433 } else if acc.seen_names.contains(&entry.name) {
434 acc.warnings.push(format!(
435 "warning: line {lineno}: duplicate entry name '{}'",
436 entry.name
437 ));
438 acc.entries.push(entry);
439 } else {
440 acc.seen_names.insert(entry.name.clone());
441 acc.entries.push(entry);
442 }
443}
444
445fn parse_source_entry(
446 parts: &[String],
447 lineno: usize,
448 source_type: &str,
449) -> (Option<Entry>, Vec<String>) {
450 if parts.len() < 3 {
451 return (
452 None,
453 vec![format!("warning: line {lineno}: too few fields, skipping")],
454 );
455 }
456 let Some(entity_type) = EntityType::parse(&parts[1]) else {
457 return (
458 None,
459 vec![format!(
460 "warning: line {lineno}: unknown entity type '{}', skipping",
461 parts[1]
462 )],
463 );
464 };
465 match source_type {
466 "github" => parse_github_entry(parts, entity_type, lineno),
467 "gitlab" => parse_gitlab_entry(parts, entity_type, lineno),
468 "local" => parse_local_entry(parts, entity_type, lineno),
469 "url" => parse_url_entry(parts, entity_type, lineno),
470 _ => (None, vec![]),
471 }
472}
473
474fn process_source_line(parts: &[String], lineno: usize, acc: &mut ParseAccumulator) {
475 let source_type = parts[0].as_str();
476 let (entry_opt, mut entry_warnings) = parse_source_entry(parts, lineno, source_type);
477 acc.warnings.append(&mut entry_warnings);
478 if let Some(entry) = entry_opt {
479 validate_and_push_entry(entry, lineno, acc);
480 }
481}
482
483pub fn parse_manifest(manifest_path: &Path) -> Result<ParseResult, SkillfileError> {
484 let raw_bytes = std::fs::read(manifest_path)?;
485
486 let text = if raw_bytes.starts_with(&[0xEF, 0xBB, 0xBF]) {
488 String::from_utf8_lossy(&raw_bytes[3..]).into_owned()
489 } else {
490 String::from_utf8_lossy(&raw_bytes).into_owned()
491 };
492
493 let mut acc = ParseAccumulator {
494 entries: Vec::new(),
495 install_targets: Vec::new(),
496 warnings: Vec::new(),
497 seen_names: HashSet::new(),
498 };
499
500 for (lineno, raw) in text.lines().enumerate() {
501 let lineno = lineno + 1; let line = raw.trim();
503 if line.is_empty() || line.starts_with('#') {
504 continue;
505 }
506
507 let Some(parts) = split_line(strip_inline_comment(line)) else {
508 acc.warnings
509 .push(format!("warning: line {lineno}: invalid quoting, skipping"));
510 continue;
511 };
512 if parts.len() < 2 {
513 acc.warnings
514 .push(format!("warning: line {lineno}: too few fields, skipping"));
515 continue;
516 }
517
518 match parts[0].as_str() {
519 "install" => parse_install_line(&parts, lineno, &mut acc),
520 "install-path" => parse_install_path_line(&parts, lineno, &mut acc),
521 _ if KNOWN_SOURCES.contains(&parts[0].as_str()) => {
522 process_source_line(&parts, lineno, &mut acc);
523 }
524 st => {
525 acc.warnings.push(format!(
526 "warning: line {lineno}: unknown source type '{st}', skipping"
527 ));
528 }
529 }
530 }
531
532 Ok(ParseResult {
533 manifest: Manifest {
534 entries: acc.entries,
535 install_targets: acc.install_targets,
536 },
537 warnings: acc.warnings,
538 })
539}
540
541#[must_use]
542pub fn parse_manifest_line(line: &str) -> Option<Entry> {
543 let parts = split_line(strip_inline_comment(line))?;
544 if parts.len() < 3 {
545 return None;
546 }
547 let source_type = parts[0].as_str();
548 if !KNOWN_SOURCES.contains(&source_type) || source_type == "install" {
549 return None;
550 }
551 let entity_type = EntityType::parse(&parts[1])?;
552 let (entry_opt, _) = match source_type {
553 "github" => parse_github_entry(&parts, entity_type, 0),
554 "gitlab" => parse_gitlab_entry(&parts, entity_type, 0),
555 "local" => parse_local_entry(&parts, entity_type, 0),
556 "url" => parse_url_entry(&parts, entity_type, 0),
557 _ => return None,
558 };
559 entry_opt
560}
561
562pub fn find_entry_in<'a>(name: &str, manifest: &'a Manifest) -> Result<&'a Entry, SkillfileError> {
563 manifest
564 .entries
565 .iter()
566 .find(|e| e.name == name)
567 .ok_or_else(|| {
568 SkillfileError::Manifest(format!("no entry named '{name}' in {MANIFEST_NAME}"))
569 })
570}
571
572#[cfg(test)]
573mod tests {
574 use super::*;
575 use std::fs;
576
577 fn dedent_line(line: &str, indent: usize) -> &str {
578 if line.len() >= indent {
579 &line[indent..]
580 } else {
581 line.trim()
582 }
583 }
584
585 fn write_manifest(dir: &Path, content: &str) -> std::path::PathBuf {
586 let p = dir.join(MANIFEST_NAME);
587 let lines: Vec<&str> = content.lines().collect();
589 let min_indent = lines
590 .iter()
591 .filter(|l| !l.trim().is_empty())
592 .map(|l| l.len() - l.trim_start().len())
593 .min()
594 .unwrap_or(0);
595 let dedented: String = lines
596 .iter()
597 .map(|l| dedent_line(l, min_indent))
598 .collect::<Vec<_>>()
599 .join("\n");
600 fs::write(&p, dedented.trim_start_matches('\n').to_string() + "\n").unwrap();
601 p
602 }
603
604 fn assert_repo_path_rejected(source_type: &str, path: &str) {
605 for line in [
606 format!("{source_type} skill owner/repo {path}"),
607 format!("{source_type} skill named owner/repo {path}"),
608 ] {
609 let dir = tempfile::tempdir().unwrap();
610 let p = write_manifest(dir.path(), &line);
611 let r = parse_manifest(&p).unwrap();
612 assert!(r.manifest.entries.is_empty(), "accepted: {line}");
613 assert!(
614 r.warnings.iter().any(|warning| warning
615 .contains(&format!("{source_type} entry has invalid repository path"))),
616 "missing warning for: {line}"
617 );
618 }
619 }
620
621 fn assert_dot_repo_path_preserved(source_type: &str) {
622 for line in [
623 format!("{source_type} skill owner/repo ."),
624 format!("{source_type} skill named owner/repo ."),
625 ] {
626 let dir = tempfile::tempdir().unwrap();
627 let p = write_manifest(dir.path(), &line);
628 let r = parse_manifest(&p).unwrap();
629 assert_eq!(r.manifest.entries.len(), 1, "rejected: {line}");
630 assert!(matches!(
631 &r.manifest.entries[0].source,
632 SourceFields::Github { path_in_repo, .. }
633 | SourceFields::Gitlab { path_in_repo, .. }
634 if path_in_repo == "."
635 ));
636 }
637 }
638
639 fn assert_empty_repo_ref_defaults_to_main(source_type: &str) {
640 for line in [
641 format!("{source_type} skill owner/repo path \"\""),
642 format!("{source_type} skill named owner/repo path \"\""),
643 format!("{source_type} skill owner/repo path \" \""),
644 format!("{source_type} skill named owner/repo path \" \""),
645 ] {
646 let dir = tempfile::tempdir().unwrap();
647 let p = write_manifest(dir.path(), &line);
648 let r = parse_manifest(&p).unwrap();
649 assert_eq!(r.manifest.entries.len(), 1, "rejected: {line}");
650 assert!(matches!(
651 &r.manifest.entries[0].source,
652 SourceFields::Github { ref_, .. } | SourceFields::Gitlab { ref_, .. }
653 if ref_ == DEFAULT_REF
654 ));
655 }
656 }
657
658 #[test]
663 fn github_entry_explicit_name_and_ref() {
664 let dir = tempfile::tempdir().unwrap();
665 let p = write_manifest(
666 dir.path(),
667 "github agent backend-dev owner/repo path/to/agent.md main",
668 );
669 let r = parse_manifest(&p).unwrap();
670 assert_eq!(r.manifest.entries.len(), 1);
671 let e = &r.manifest.entries[0];
672 assert_eq!(e.source_type(), "github");
673 assert_eq!(e.entity_type, EntityType::Agent);
674 assert_eq!(e.name, "backend-dev");
675 assert_eq!(e.owner_repo(), "owner/repo");
676 assert_eq!(e.path_in_repo(), "path/to/agent.md");
677 assert_eq!(e.ref_(), "main");
678 }
679
680 #[test]
681 fn local_entry_bare_dir_name() {
682 let dir = tempfile::tempdir().unwrap();
683 let p = write_manifest(dir.path(), "local skill bash-craftsman");
684 let r = parse_manifest(&p).unwrap();
685 assert!(
686 r.warnings.is_empty(),
687 "unexpected warnings: {:?}",
688 r.warnings
689 );
690 assert_eq!(r.manifest.entries.len(), 1);
691 let e = &r.manifest.entries[0];
692 assert_eq!(e.source_type(), "local");
693 assert_eq!(e.entity_type, EntityType::Skill);
694 assert_eq!(e.name, "bash-craftsman");
695 assert_eq!(e.local_path(), "bash-craftsman");
696 }
697
698 #[test]
699 fn local_entry_explicit_name() {
700 let dir = tempfile::tempdir().unwrap();
701 let p = write_manifest(dir.path(), "local skill git-commit skills/git/commit.md");
702 let r = parse_manifest(&p).unwrap();
703 assert_eq!(r.manifest.entries.len(), 1);
704 let e = &r.manifest.entries[0];
705 assert_eq!(e.source_type(), "local");
706 assert_eq!(e.entity_type, EntityType::Skill);
707 assert_eq!(e.name, "git-commit");
708 assert_eq!(e.local_path(), "skills/git/commit.md");
709 }
710
711 #[test]
712 fn url_entry_explicit_name() {
713 let dir = tempfile::tempdir().unwrap();
714 let p = write_manifest(
715 dir.path(),
716 "url skill my-skill https://example.com/skill.md",
717 );
718 let r = parse_manifest(&p).unwrap();
719 assert_eq!(r.manifest.entries.len(), 1);
720 let e = &r.manifest.entries[0];
721 assert_eq!(e.source_type(), "url");
722 assert_eq!(e.name, "my-skill");
723 assert_eq!(e.url(), "https://example.com/skill.md");
724 }
725
726 #[test]
731 fn github_entry_inferred_name() {
732 let dir = tempfile::tempdir().unwrap();
733 let p = write_manifest(
734 dir.path(),
735 "github agent owner/repo path/to/agent.md main",
736 );
737 let r = parse_manifest(&p).unwrap();
738 assert_eq!(r.manifest.entries.len(), 1);
739 let e = &r.manifest.entries[0];
740 assert_eq!(e.name, "agent");
741 assert_eq!(e.owner_repo(), "owner/repo");
742 assert_eq!(e.path_in_repo(), "path/to/agent.md");
743 assert_eq!(e.ref_(), "main");
744 }
745
746 #[test]
747 fn local_entry_inferred_name_from_path() {
748 let dir = tempfile::tempdir().unwrap();
749 let p = write_manifest(dir.path(), "local skill skills/git/commit.md");
750 let r = parse_manifest(&p).unwrap();
751 assert_eq!(r.manifest.entries.len(), 1);
752 let e = &r.manifest.entries[0];
753 assert_eq!(e.name, "commit");
754 assert_eq!(e.local_path(), "skills/git/commit.md");
755 }
756
757 #[test]
758 fn local_entry_inferred_name_from_md_extension() {
759 let dir = tempfile::tempdir().unwrap();
760 let p = write_manifest(dir.path(), "local skill commit.md");
761 let r = parse_manifest(&p).unwrap();
762 assert_eq!(r.manifest.entries.len(), 1);
763 assert_eq!(r.manifest.entries[0].name, "commit");
764 }
765
766 #[test]
767 fn url_entry_inferred_name() {
768 let dir = tempfile::tempdir().unwrap();
769 let p = write_manifest(dir.path(), "url skill https://example.com/my-skill.md");
770 let r = parse_manifest(&p).unwrap();
771 assert_eq!(r.manifest.entries.len(), 1);
772 let e = &r.manifest.entries[0];
773 assert_eq!(e.name, "my-skill");
774 assert_eq!(e.url(), "https://example.com/my-skill.md");
775 }
776
777 #[test]
782 fn github_entry_inferred_name_default_ref() {
783 let dir = tempfile::tempdir().unwrap();
784 let p = write_manifest(dir.path(), "github agent owner/repo path/to/agent.md");
785 let r = parse_manifest(&p).unwrap();
786 assert_eq!(r.manifest.entries[0].ref_(), "main");
787 }
788
789 #[test]
790 fn github_entry_explicit_name_default_ref() {
791 let dir = tempfile::tempdir().unwrap();
792 let p = write_manifest(
793 dir.path(),
794 "github agent my-agent owner/repo path/to/agent.md",
795 );
796 let r = parse_manifest(&p).unwrap();
797 assert_eq!(r.manifest.entries[0].ref_(), "main");
798 }
799
800 #[test]
801 fn repository_sources_reject_empty_and_root_only_paths() {
802 for (source_type, path) in [
803 ("github", "\"\""),
804 ("github", "\" \""),
805 ("github", "/"),
806 ("github", "//"),
807 ("github", "/./"),
808 ("gitlab", "\"\""),
809 ("gitlab", "\" \""),
810 ("gitlab", "/"),
811 ("gitlab", "//"),
812 ("gitlab", "/./"),
813 ] {
814 assert_repo_path_rejected(source_type, path);
815 }
816 }
817
818 #[test]
819 fn repository_sources_preserve_dot_root_path() {
820 for source_type in ["github", "gitlab"] {
821 assert_dot_repo_path_preserved(source_type);
822 }
823 }
824
825 #[test]
826 fn repository_sources_default_empty_refs() {
827 for source_type in ["github", "gitlab"] {
828 assert_empty_repo_ref_defaults_to_main(source_type);
829 }
830 }
831
832 #[test]
833 fn empty_source_and_install_fields_are_rejected() {
834 for (line, warning) in [
835 ("local skill \"\"", "local entry path must not be empty"),
836 (
837 "local skill named \" \"",
838 "local entry path must not be empty",
839 ),
840 ("url skill named \"\"", "url entry URL must not be empty"),
841 (
842 "install \" \" global",
843 "install adapter must not be empty",
844 ),
845 ] {
846 let dir = tempfile::tempdir().unwrap();
847 let p = write_manifest(dir.path(), line);
848 let result = parse_manifest(&p).unwrap();
849
850 assert!(result.manifest.entries.is_empty(), "accepted: {line}");
851 assert!(
852 result.manifest.install_targets.is_empty(),
853 "accepted: {line}"
854 );
855 assert!(
856 result
857 .warnings
858 .iter()
859 .any(|message| message.contains(warning)),
860 "missing warning for: {line}"
861 );
862 }
863 }
864
865 #[test]
870 fn github_entry_at_ref_inferred_name() {
871 let dir = tempfile::tempdir().unwrap();
872 let p = write_manifest(dir.path(), "github skill nuxt/ui@v4 path/to/SKILL.md");
873 let r = parse_manifest(&p).unwrap();
874 assert_eq!(r.manifest.entries.len(), 1);
875 let e = &r.manifest.entries[0];
876 assert_eq!(e.name, "SKILL");
877 assert_eq!(e.owner_repo(), "nuxt/ui");
878 assert_eq!(e.ref_(), "v4");
879 }
880
881 #[test]
882 fn github_entry_at_ref_explicit_name() {
883 let dir = tempfile::tempdir().unwrap();
884 let p = write_manifest(
885 dir.path(),
886 "github skill my-skill nuxt/ui@v4 path/to/SKILL.md",
887 );
888 let r = parse_manifest(&p).unwrap();
889 assert_eq!(r.manifest.entries.len(), 1);
890 let e = &r.manifest.entries[0];
891 assert_eq!(e.name, "my-skill");
892 assert_eq!(e.owner_repo(), "nuxt/ui");
893 assert_eq!(e.ref_(), "v4");
894 }
895
896 #[test]
897 fn github_entry_at_ref_with_main() {
898 let dir = tempfile::tempdir().unwrap();
899 let p = write_manifest(
900 dir.path(),
901 "github skill owner/repo@main path/to/SKILL.md",
902 );
903 let r = parse_manifest(&p).unwrap();
904 assert_eq!(r.manifest.entries[0].owner_repo(), "owner/repo");
905 assert_eq!(r.manifest.entries[0].ref_(), "main");
906 }
907
908 #[test]
909 fn github_entry_at_ref_with_sha() {
910 let dir = tempfile::tempdir().unwrap();
911 let p = write_manifest(
912 dir.path(),
913 "github skill owner/repo@abc123def456 path/to/SKILL.md",
914 );
915 let r = parse_manifest(&p).unwrap();
916 assert_eq!(r.manifest.entries[0].owner_repo(), "owner/repo");
917 assert_eq!(r.manifest.entries[0].ref_(), "abc123def456");
918 }
919
920 #[test]
921 fn github_entry_at_ref_takes_priority_over_positional() {
922 let dir = tempfile::tempdir().unwrap();
923 let p = write_manifest(
924 dir.path(),
925 "github skill nuxt/ui@v4 path/to/SKILL.md v3",
926 );
927 let r = parse_manifest(&p).unwrap();
928 let e = &r.manifest.entries[0];
929 assert_eq!(e.owner_repo(), "nuxt/ui");
930 assert_eq!(e.ref_(), "v4");
931 }
932
933 #[test]
934 fn github_entry_at_ref_requires_owner_repo_before_ref_separator() {
935 let dir = tempfile::tempdir().unwrap();
936 let p = write_manifest(dir.path(), "github skill us@tal/repo path/to/SKILL.md");
937 let r = parse_manifest(&p).unwrap();
938 assert!(r.manifest.entries.is_empty());
939 assert!(r
940 .warnings
941 .iter()
942 .any(|warning| warning.contains("invalid owner/repo 'us@tal/repo'")));
943 }
944
945 #[test]
946 fn github_entry_at_ref_requires_owner_repo_before_ref_separator_with_name() {
947 let dir = tempfile::tempdir().unwrap();
948 let p = write_manifest(
949 dir.path(),
950 "github skill my-skill us@tal/repo path/to/SKILL.md",
951 );
952 let r = parse_manifest(&p).unwrap();
953 assert!(r.manifest.entries.is_empty());
954 assert!(r
955 .warnings
956 .iter()
957 .any(|warning| warning.contains("invalid owner/repo 'us@tal/repo'")));
958 }
959
960 #[test]
965 fn install_target_parsed() {
966 let dir = tempfile::tempdir().unwrap();
967 let p = write_manifest(dir.path(), "install claude-code global");
968 let r = parse_manifest(&p).unwrap();
969 assert_eq!(r.manifest.install_targets.len(), 1);
970 let t = &r.manifest.install_targets[0];
971 assert_eq!(t, &InstallTarget::platform("claude-code", Scope::Global));
972 }
973
974 #[test]
975 fn multiple_install_targets() {
976 let dir = tempfile::tempdir().unwrap();
977 let p = write_manifest(
978 dir.path(),
979 "install claude-code global\ninstall claude-code local",
980 );
981 let r = parse_manifest(&p).unwrap();
982 assert_eq!(r.manifest.install_targets.len(), 2);
983 assert_eq!(
984 r.manifest.install_targets[0],
985 InstallTarget::platform("claude-code", Scope::Global)
986 );
987 assert_eq!(
988 r.manifest.install_targets[1],
989 InstallTarget::platform("claude-code", Scope::Local)
990 );
991 }
992
993 #[test]
994 fn install_path_target_parsed() {
995 let dir = tempfile::tempdir().unwrap();
996 let p = write_manifest(
997 dir.path(),
998 "install-path openclaw skill ~/.openclaw/skills",
999 );
1000 let r = parse_manifest(&p).unwrap();
1001 assert_eq!(r.manifest.install_targets.len(), 1);
1002 assert_eq!(
1003 r.manifest.install_targets[0],
1004 InstallTarget::path("openclaw", EntityType::Skill, "~/.openclaw/skills")
1005 );
1006 }
1007
1008 #[test]
1009 fn install_targets_not_in_entries() {
1010 let dir = tempfile::tempdir().unwrap();
1011 let p = write_manifest(
1012 dir.path(),
1013 "install claude-code global\ngithub agent owner/repo path/to/agent.md",
1014 );
1015 let r = parse_manifest(&p).unwrap();
1016 assert_eq!(r.manifest.entries.len(), 1);
1017 assert_eq!(r.manifest.install_targets.len(), 1);
1018 }
1019
1020 #[test]
1025 fn comments_and_blanks_skipped() {
1026 let dir = tempfile::tempdir().unwrap();
1027 let p = write_manifest(
1028 dir.path(),
1029 "# this is a comment\n\n# another comment\nlocal skill foo skills/foo.md",
1030 );
1031 let r = parse_manifest(&p).unwrap();
1032 assert_eq!(r.manifest.entries.len(), 1);
1033 }
1034
1035 #[test]
1036 fn malformed_too_few_fields() {
1037 let dir = tempfile::tempdir().unwrap();
1038 let p = write_manifest(dir.path(), "github agent");
1039 let r = parse_manifest(&p).unwrap();
1040 assert!(r.manifest.entries.is_empty());
1041 assert!(r.warnings.iter().any(|w| w.contains("warning")));
1042 }
1043
1044 #[test]
1045 fn unknown_source_type_skipped() {
1046 let dir = tempfile::tempdir().unwrap();
1047 let p = write_manifest(dir.path(), "svn skill foo some/path");
1048 let r = parse_manifest(&p).unwrap();
1049 assert!(r.manifest.entries.is_empty());
1050 assert!(r.warnings.iter().any(|w| w.contains("warning")));
1051 assert!(r.warnings.iter().any(|w| w.contains("svn")));
1052 }
1053
1054 #[test]
1059 fn inline_comment_stripped() {
1060 let dir = tempfile::tempdir().unwrap();
1061 let p = write_manifest(
1062 dir.path(),
1063 "github agent owner/repo agents/foo.md # my note",
1064 );
1065 let r = parse_manifest(&p).unwrap();
1066 assert_eq!(r.manifest.entries.len(), 1);
1067 let e = &r.manifest.entries[0];
1068 assert_eq!(e.ref_(), "main"); assert_eq!(e.name, "foo");
1070 }
1071
1072 #[test]
1073 fn inline_comment_on_install_line() {
1074 let dir = tempfile::tempdir().unwrap();
1075 let p = write_manifest(dir.path(), "install claude-code global # primary target");
1076 let r = parse_manifest(&p).unwrap();
1077 assert_eq!(r.manifest.install_targets.len(), 1);
1078 assert_eq!(
1079 r.manifest.install_targets[0],
1080 InstallTarget::platform("claude-code", Scope::Global)
1081 );
1082 }
1083
1084 #[test]
1085 fn inline_comment_on_install_path_line() {
1086 let dir = tempfile::tempdir().unwrap();
1087 let p = write_manifest(
1088 dir.path(),
1089 "install-path misc-target agent ./agents # custom target",
1090 );
1091 let r = parse_manifest(&p).unwrap();
1092 assert_eq!(r.manifest.install_targets.len(), 1);
1093 assert_eq!(
1094 r.manifest.install_targets[0],
1095 InstallTarget::path("misc-target", EntityType::Agent, "./agents")
1096 );
1097 }
1098
1099 #[test]
1100 fn inline_comment_after_ref() {
1101 let dir = tempfile::tempdir().unwrap();
1102 let p = write_manifest(
1103 dir.path(),
1104 "github agent my-agent owner/repo agents/foo.md v1.0 # pinned version",
1105 );
1106 let r = parse_manifest(&p).unwrap();
1107 assert_eq!(r.manifest.entries[0].ref_(), "v1.0");
1108 }
1109
1110 #[test]
1115 fn quoted_path_with_spaces() {
1116 let dir = tempfile::tempdir().unwrap();
1117 let p = dir.path().join(MANIFEST_NAME);
1118 fs::write(&p, "local skill my-skill \"skills/my dir/foo.md\"\n").unwrap();
1119 let r = parse_manifest(&p).unwrap();
1120 assert_eq!(r.manifest.entries.len(), 1);
1121 assert_eq!(r.manifest.entries[0].local_path(), "skills/my dir/foo.md");
1122 }
1123
1124 #[test]
1125 fn quoted_github_path() {
1126 let dir = tempfile::tempdir().unwrap();
1127 let p = dir.path().join(MANIFEST_NAME);
1128 fs::write(
1129 &p,
1130 "github skill owner/repo \"path with spaces/skill.md\"\n",
1131 )
1132 .unwrap();
1133 let r = parse_manifest(&p).unwrap();
1134 assert_eq!(r.manifest.entries.len(), 1);
1135 assert_eq!(
1136 r.manifest.entries[0].path_in_repo(),
1137 "path with spaces/skill.md"
1138 );
1139 }
1140
1141 #[test]
1142 fn quoted_hash_path_is_not_an_inline_comment() {
1143 let dir = tempfile::tempdir().unwrap();
1144 let p = dir.path().join(MANIFEST_NAME);
1145 fs::write(&p, "local skill hash-skill \"#skills/hash.md\"\n").unwrap();
1146 let r = parse_manifest(&p).unwrap();
1147 assert_eq!(r.manifest.entries.len(), 1);
1148 assert_eq!(r.manifest.entries[0].name, "hash-skill");
1149 assert_eq!(r.manifest.entries[0].local_path(), "#skills/hash.md");
1150 }
1151
1152 #[test]
1153 fn single_quoted_hash_path_is_not_an_inline_comment() {
1154 let dir = tempfile::tempdir().unwrap();
1155 let p = dir.path().join(MANIFEST_NAME);
1156 fs::write(&p, "local skill hash-skill 'my #skills/hash.md'\n").unwrap();
1157 let r = parse_manifest(&p).unwrap();
1158 assert_eq!(r.manifest.entries.len(), 1);
1159 assert_eq!(r.manifest.entries[0].local_path(), "my #skills/hash.md");
1160 }
1161
1162 #[test]
1163 fn mixed_quoted_and_unquoted() {
1164 let dir = tempfile::tempdir().unwrap();
1165 let p = dir.path().join(MANIFEST_NAME);
1166 fs::write(
1167 &p,
1168 "github agent my-agent owner/repo \"agents/path with spaces/foo.md\"\n",
1169 )
1170 .unwrap();
1171 let r = parse_manifest(&p).unwrap();
1172 assert_eq!(r.manifest.entries.len(), 1);
1173 assert_eq!(r.manifest.entries[0].name, "my-agent");
1174 assert_eq!(
1175 r.manifest.entries[0].path_in_repo(),
1176 "agents/path with spaces/foo.md"
1177 );
1178 }
1179
1180 #[test]
1181 fn unquoted_fields_parse_identically() {
1182 let dir = tempfile::tempdir().unwrap();
1183 let p = write_manifest(
1184 dir.path(),
1185 "github agent backend-dev owner/repo path/to/agent.md main",
1186 );
1187 let r = parse_manifest(&p).unwrap();
1188 assert_eq!(r.manifest.entries[0].name, "backend-dev");
1189 assert_eq!(r.manifest.entries[0].ref_(), "main");
1190 }
1191
1192 #[test]
1197 fn valid_entry_name_accepted() {
1198 let dir = tempfile::tempdir().unwrap();
1199 let p = write_manifest(dir.path(), "local skill my-skill_v2.0 skills/foo.md");
1200 let r = parse_manifest(&p).unwrap();
1201 assert_eq!(r.manifest.entries.len(), 1);
1202 assert_eq!(r.manifest.entries[0].name, "my-skill_v2.0");
1203 }
1204
1205 #[test]
1206 fn invalid_entry_name_rejected() {
1207 let dir = tempfile::tempdir().unwrap();
1208 let p = dir.path().join(MANIFEST_NAME);
1209 fs::write(&p, "local skill \"my skill!\" skills/foo.md\n").unwrap();
1210 let r = parse_manifest(&p).unwrap();
1211 assert!(r.manifest.entries.is_empty());
1212 assert!(r
1213 .warnings
1214 .iter()
1215 .any(|w| w.to_lowercase().contains("invalid name")
1216 || w.to_lowercase().contains("warning")));
1217 }
1218
1219 #[test]
1220 fn inferred_name_validated() {
1221 let dir = tempfile::tempdir().unwrap();
1222 let p = write_manifest(dir.path(), "local skill skills/foo.md");
1223 let r = parse_manifest(&p).unwrap();
1224 assert_eq!(r.manifest.entries.len(), 1);
1225 assert_eq!(r.manifest.entries[0].name, "foo");
1226 }
1227
1228 #[test]
1233 fn valid_scope_accepted() {
1234 for (scope_str, expected) in &[("global", Scope::Global), ("local", Scope::Local)] {
1235 let dir = tempfile::tempdir().unwrap();
1236 let p = write_manifest(dir.path(), &format!("install claude-code {scope_str}"));
1237 let r = parse_manifest(&p).unwrap();
1238 assert_eq!(r.manifest.install_targets.len(), 1);
1239 assert_eq!(
1240 r.manifest.install_targets[0],
1241 InstallTarget::platform("claude-code", *expected)
1242 );
1243 }
1244 }
1245
1246 #[test]
1247 fn invalid_scope_rejected() {
1248 let dir = tempfile::tempdir().unwrap();
1249 let p = write_manifest(dir.path(), "install claude-code worldwide");
1250 let r = parse_manifest(&p).unwrap();
1251 assert!(r.manifest.install_targets.is_empty());
1252 assert!(r
1253 .warnings
1254 .iter()
1255 .any(|w| w.to_lowercase().contains("scope") || w.to_lowercase().contains("warning")));
1256 }
1257
1258 #[test]
1259 fn invalid_install_path_entity_type_rejected() {
1260 let dir = tempfile::tempdir().unwrap();
1261 let p = write_manifest(dir.path(), "install-path misc-target bot ./target");
1262 let r = parse_manifest(&p).unwrap();
1263 assert!(r.manifest.install_targets.is_empty());
1264 assert!(r.warnings.iter().any(|w| w.contains("invalid entity type")));
1265 }
1266
1267 #[test]
1268 fn empty_install_path_tool_name_rejected() {
1269 let dir = tempfile::tempdir().unwrap();
1270 let p = write_manifest(dir.path(), "install-path \"\" skill ./target");
1271 let r = parse_manifest(&p).unwrap();
1272 assert!(r.manifest.install_targets.is_empty());
1273 assert!(r.warnings.iter().any(|w| w.contains("tool-name")));
1274 }
1275
1276 #[test]
1277 fn empty_install_path_path_rejected() {
1278 let dir = tempfile::tempdir().unwrap();
1279 let p = write_manifest(dir.path(), "install-path misc-target skill \"\"");
1280 let r = parse_manifest(&p).unwrap();
1281 assert!(r.manifest.install_targets.is_empty());
1282 assert!(r.warnings.iter().any(|w| w.contains("path")));
1283 }
1284
1285 #[test]
1290 fn duplicate_entry_name_warns() {
1291 let dir = tempfile::tempdir().unwrap();
1292 let p = write_manifest(
1293 dir.path(),
1294 "local skill foo skills/foo.md\nlocal agent foo agents/foo.md",
1295 );
1296 let r = parse_manifest(&p).unwrap();
1297 assert_eq!(r.manifest.entries.len(), 2); assert!(r
1299 .warnings
1300 .iter()
1301 .any(|w| w.to_lowercase().contains("duplicate")));
1302 }
1303
1304 #[test]
1309 fn utf8_bom_handled() {
1310 let dir = tempfile::tempdir().unwrap();
1311 let p = dir.path().join(MANIFEST_NAME);
1312 let mut content = vec![0xEF, 0xBB, 0xBF]; content.extend_from_slice(b"install claude-code global\n");
1314 fs::write(&p, content).unwrap();
1315 let r = parse_manifest(&p).unwrap();
1316 assert_eq!(r.manifest.install_targets.len(), 1);
1317 assert_eq!(
1318 r.manifest.install_targets[0],
1319 InstallTarget::platform("claude-code", Scope::Global)
1320 );
1321 }
1322
1323 #[test]
1328 fn unknown_entity_type_skipped_with_warning() {
1329 let dir = tempfile::tempdir().unwrap();
1330 let p = write_manifest(dir.path(), "local hook foo hooks/foo.md");
1331 let r = parse_manifest(&p).unwrap();
1332 assert!(r.manifest.entries.is_empty());
1333 assert!(r.warnings.iter().any(|w| w.contains("unknown entity type")));
1334 }
1335
1336 #[test]
1337 fn github_invalid_owner_repo_skipped_with_warning() {
1338 let dir = tempfile::tempdir().unwrap();
1339 let p = write_manifest(dir.path(), "github skill my-skill noslash path.md");
1341 let r = parse_manifest(&p).unwrap();
1342 assert!(
1343 r.manifest.entries.is_empty(),
1344 "entry with invalid owner/repo should be skipped"
1345 );
1346 assert!(r.warnings.iter().any(|w| w.contains("owner/repo")));
1347 }
1348
1349 #[test]
1350 fn github_invalid_owner_repo_after_lossy_utf8_decode_skipped_with_warning() {
1351 let dir = tempfile::tempdir().unwrap();
1352 let p = dir.path().join(MANIFEST_NAME);
1353 fs::write(
1354 &p,
1355 [
1356 240, 174, 174, 174, 240, 174, 174, 170, 240, 105, 116, 104, 117, 97, 10, 103, 105,
1357 116, 104, 117, 98, 32, 97, 103, 101, 110, 116, 32, 117, 115, 64, 116, 97, 108, 170,
1358 170, 115, 47, 108, 1, 57, 32, 108, 32, 59, 239, 191, 10,
1359 ],
1360 )
1361 .unwrap();
1362 let r = parse_manifest(&p).unwrap();
1363 assert!(r.manifest.entries.is_empty());
1364 assert!(r.warnings.iter().any(|w| w.contains("invalid owner/repo")));
1365 }
1366
1367 #[test]
1372 fn find_entry_in_found() {
1373 let e = Entry {
1374 entity_type: EntityType::Skill,
1375 name: "foo".into(),
1376 source: SourceFields::Local {
1377 path: "foo.md".into(),
1378 },
1379 };
1380 let m = Manifest {
1381 entries: vec![e.clone()],
1382 install_targets: vec![],
1383 };
1384 assert_eq!(find_entry_in("foo", &m).unwrap(), &e);
1385 }
1386
1387 #[test]
1388 fn find_entry_in_not_found() {
1389 let m = Manifest::default();
1390 assert!(find_entry_in("missing", &m).is_err());
1391 }
1392
1393 #[test]
1398 fn infer_name_from_md_path() {
1399 assert_eq!(infer_name("path/to/agent.md"), "agent");
1400 }
1401
1402 #[test]
1403 fn infer_name_from_dot() {
1404 assert_eq!(infer_name("."), "content");
1405 }
1406
1407 #[test]
1408 fn infer_name_from_url() {
1409 assert_eq!(infer_name("https://example.com/my-skill.md"), "my-skill");
1410 }
1411
1412 #[test]
1417 fn split_line_simple() {
1418 assert_eq!(
1419 split_line("github agent owner/repo agent.md").unwrap(),
1420 vec!["github", "agent", "owner/repo", "agent.md"]
1421 );
1422 }
1423
1424 #[test]
1425 fn split_line_quoted() {
1426 assert_eq!(
1427 split_line("local skill \"my dir/foo.md\"").unwrap(),
1428 vec!["local", "skill", "my dir/foo.md"]
1429 );
1430 }
1431
1432 #[test]
1433 fn split_line_single_quoted() {
1434 assert_eq!(
1435 split_line("local skill 'my dir/foo.md'").unwrap(),
1436 vec!["local", "skill", "my dir/foo.md"]
1437 );
1438 }
1439
1440 #[test]
1441 fn quote_field_round_trips_shell_sensitive_values() {
1442 for field in [
1443 r"C:\skills\win.md",
1444 "skills/it's.md",
1445 r#"skills/"quoted".md"#,
1446 "skills/my dir/#skill.md",
1447 ] {
1448 assert_eq!(
1449 split_line("e_field(field)),
1450 Some(vec![field.to_string()])
1451 );
1452 }
1453 }
1454
1455 #[test]
1456 fn invalid_quoting_warns_and_skips_line() {
1457 let dir = tempfile::tempdir().unwrap();
1458 let p = write_manifest(dir.path(), "local skill \"unterminated.md");
1459 let r = parse_manifest(&p).unwrap();
1460 assert!(r.manifest.entries.is_empty());
1461 assert!(r.warnings.iter().any(|w| w.contains("invalid quoting")));
1462 }
1463
1464 #[test]
1465 fn split_line_tabs() {
1466 assert_eq!(
1467 split_line("local\tskill\tfoo.md").unwrap(),
1468 vec!["local", "skill", "foo.md"]
1469 );
1470 }
1471
1472 #[test]
1477 fn gitlab_entry_explicit_name_and_ref() {
1478 let dir = tempfile::tempdir().unwrap();
1479 let p = write_manifest(
1480 dir.path(),
1481 "gitlab skill my-skill my-group/my-project skills/my-skill.md v2.0",
1482 );
1483 let r = parse_manifest(&p).unwrap();
1484 assert_eq!(r.manifest.entries.len(), 1);
1485 let e = &r.manifest.entries[0];
1486 assert_eq!(e.source_type(), "gitlab");
1487 assert_eq!(e.entity_type, EntityType::Skill);
1488 assert_eq!(e.name, "my-skill");
1489 let (or, pir, ref_) = e.source.as_gitlab().unwrap();
1490 assert_eq!(or, "my-group/my-project");
1491 assert_eq!(pir, "skills/my-skill.md");
1492 assert_eq!(ref_, "v2.0");
1493 }
1494
1495 #[test]
1496 fn gitlab_entry_inferred_name_default_ref() {
1497 let dir = tempfile::tempdir().unwrap();
1498 let p = write_manifest(
1499 dir.path(),
1500 "gitlab agent my-group/my-project agents/reviewer.md",
1501 );
1502 let r = parse_manifest(&p).unwrap();
1503 assert_eq!(r.manifest.entries.len(), 1);
1504 let e = &r.manifest.entries[0];
1505 assert_eq!(e.source_type(), "gitlab");
1506 assert_eq!(e.name, "reviewer");
1507 let (or, pir, ref_) = e.source.as_gitlab().unwrap();
1508 assert_eq!(or, "my-group/my-project");
1509 assert_eq!(pir, "agents/reviewer.md");
1510 assert_eq!(ref_, "main");
1511 }
1512
1513 #[test]
1514 fn gitlab_entry_too_few_fields_warns() {
1515 let dir = tempfile::tempdir().unwrap();
1516 let p = write_manifest(dir.path(), "gitlab skill");
1517 let r = parse_manifest(&p).unwrap();
1518 assert!(r.manifest.entries.is_empty());
1519 assert!(r.warnings.iter().any(|w| w.contains("warning")));
1520 }
1521}