Skip to main content

skillfile_core/
parser.rs

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/// Result of parsing a Skillfile: the manifest plus any warnings.
11#[derive(Debug)]
12pub struct ParseResult {
13    pub manifest: Manifest,
14    pub warnings: Vec<String>,
15}
16
17/// Infer an entry name from a path or URL (filename stem).
18///
19/// ```
20/// use skillfile_core::parser::infer_name;
21/// assert_eq!(infer_name("skills/requesting-code-review"), "requesting-code-review");
22/// assert_eq!(infer_name("agents/code-refactorer.md"), "code-refactorer");
23/// assert_eq!(infer_name("https://example.com/browser-skill.md"), "browser-skill");
24/// assert_eq!(infer_name("."), "content");
25/// ```
26#[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
35/// Check if a name is filesystem-safe: alphanumeric, dot, hyphen, underscore.
36fn 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
43/// Split a manifest line using shell-style field quoting.
44///
45/// Unquoted lines split identically to whitespace split.
46/// Quoted fields preserve internal spaces and comment markers.
47pub 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/// Quote a manifest field so shell-style parsing recovers the original value.
78#[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/// Parse `owner/repo[@ref]` into `(owner_repo, ref_)`.
87///
88/// Supports:
89/// - `owner/repo` → `("owner/repo", None)`
90/// - `owner/repo@v4` → `("owner/repo", Some("v4"))`
91/// - `owner/repo@main` → `("owner/repo", Some("main"))`
92#[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/// Resolve the effective ref from the `@`-syntax parsed ref and an explicit positional ref.
103///
104/// Priority: `owner/repo@ref` > explicit positional ref > `DEFAULT_REF`.
105#[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/// Resolve the effective ref from the `@`-syntax parsed ref and an explicit positional ref.
118///
119/// Priority: `owner/repo@ref` > explicit positional ref > `DEFAULT_REF`.
120#[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
153/// Parse a github entry line. parts[0]=source_type, parts[1]=entity_type, etc.
154fn 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    // Detection: if parts[2] contains '/' → it's owner/repo (inferred name)
162    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
209/// Parse a gitlab entry line. Identical logic to github but creates `SourceFields::Gitlab`.
210fn 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    // Detection: if parts[2] ends in ".md" or contains '/' → path (inferred name).
268    // With 4+ parts, parts[2] is always the explicit name and parts[3] the path.
269    // With exactly 3 parts, parts[2] is always the path (even bare directory names
270    // like "commit" that don't contain '/' or end in '.md').
271    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    // Detection: if parts[2] starts with "http" → URL (inferred name)
308    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    // Strip UTF-8 BOM if present
487    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; // 1-indexed
502        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        // Dedent: strip leading whitespace common to all non-empty lines
588        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    // -------------------------------------------------------------------
659    // Existing entry types (explicit name + ref)
660    // -------------------------------------------------------------------
661
662    #[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    // -------------------------------------------------------------------
727    // Optional name inference
728    // -------------------------------------------------------------------
729
730    #[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    // -------------------------------------------------------------------
778    // Optional ref (defaults to main)
779    // -------------------------------------------------------------------
780
781    #[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    // -------------------------------------------------------------------
866    // @-syntax ref (owner/repo@ref)
867    // -------------------------------------------------------------------
868
869    #[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    // -------------------------------------------------------------------
961    // Install targets
962    // -------------------------------------------------------------------
963
964    #[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    // -------------------------------------------------------------------
1021    // Comments, blanks, errors
1022    // -------------------------------------------------------------------
1023
1024    #[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    // -------------------------------------------------------------------
1055    // Inline comments
1056    // -------------------------------------------------------------------
1057
1058    #[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"); // not "#"
1069        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    // -------------------------------------------------------------------
1111    // Quoted fields
1112    // -------------------------------------------------------------------
1113
1114    #[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    // -------------------------------------------------------------------
1193    // Name validation
1194    // -------------------------------------------------------------------
1195
1196    #[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    // -------------------------------------------------------------------
1229    // Scope validation
1230    // -------------------------------------------------------------------
1231
1232    #[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    // -------------------------------------------------------------------
1286    // Duplicate entry name warning
1287    // -------------------------------------------------------------------
1288
1289    #[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); // both included
1298        assert!(r
1299            .warnings
1300            .iter()
1301            .any(|w| w.to_lowercase().contains("duplicate")));
1302    }
1303
1304    // -------------------------------------------------------------------
1305    // UTF-8 BOM handling
1306    // -------------------------------------------------------------------
1307
1308    #[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]; // BOM
1313        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    // -------------------------------------------------------------------
1324    // Unknown entity type warning
1325    // -------------------------------------------------------------------
1326
1327    #[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        // Explicit-name form: name is "my-skill", owner_repo is "noslash"
1340        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    // -------------------------------------------------------------------
1368    // find_entry_in
1369    // -------------------------------------------------------------------
1370
1371    #[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    // -------------------------------------------------------------------
1394    // infer_name
1395    // -------------------------------------------------------------------
1396
1397    #[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    // -------------------------------------------------------------------
1413    // split_line
1414    // -------------------------------------------------------------------
1415
1416    #[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(&quote_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    // -------------------------------------------------------------------
1473    // GitLab entries
1474    // -------------------------------------------------------------------
1475
1476    #[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}