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