use std::path::{Path, PathBuf};
use crate::error::{Result, SkillsError};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SourceType {
Local,
Github,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Source {
pub ty: SourceType,
pub raw: String,
pub local_path: Option<PathBuf>,
pub owner: String,
pub repo: String,
pub slug: String,
}
impl Source {
pub fn id(&self) -> String {
format!("{}/{}/{}", self.owner, self.repo, self.slug)
}
}
fn is_local_path(input: &str) -> bool {
let p = Path::new(input);
if p.is_absolute() {
return true;
}
if p.has_root() {
return true;
}
if input.starts_with("./") || input.starts_with("../") {
return true;
}
if input.starts_with(".\\") || input.starts_with("..\\") {
return true;
}
if input == "." || input == ".." {
return true;
}
let b = input.as_bytes();
b.len() >= 3 && b[0].is_ascii_alphabetic() && b[1] == b':' && (b[2] == b'/' || b[2] == b'\\')
}
fn unsupported(input: &str, hint: &str) -> SkillsError {
SkillsError::msg(format!(
"Unsupported source: \"{input}\". {hint} Supported forms: \
`owner/repo/slug` (GitHub) or a local skill directory containing SKILL.md."
))
}
fn valid_segment(s: &str) -> bool {
!s.is_empty() && s != "." && s != ".."
}
fn parse_github_id(input: &str) -> Result<Option<Source>> {
if input.contains(':') {
return Err(unsupported(
input,
"SSH and generic git URLs are no longer supported.",
));
}
if input.starts_with('.') || input.starts_with('/') {
return Ok(None);
}
let segments: Vec<&str> = input.split('/').collect();
if segments.len() < 2 {
return Ok(None);
}
if segments.len() == 2 {
let hint = if input.contains('@') {
"The `owner/repo@<skill>` form is no longer supported; install by \
id instead: `owner/repo/<slug>`."
} else {
"An id has three segments: `owner/repo/<slug>`."
};
return Err(unsupported(input, hint));
}
if segments.len() > 3 {
return Err(unsupported(
input,
"An id has exactly three segments: `owner/repo/<slug>`.",
));
}
let owner = segments[0];
let repo = segments[1];
let slug = segments[2];
if !valid_segment(owner) || !valid_segment(repo) || !valid_segment(slug) {
return Err(unsupported(
input,
"Expected `owner/repo/<slug>` — no segment may be empty, `.` or `..`.",
));
}
Ok(Some(Source {
ty: SourceType::Github,
raw: input.to_string(),
local_path: None,
owner: owner.to_string(),
repo: repo.strip_suffix(".git").unwrap_or(repo).to_string(),
slug: slug.to_string(),
}))
}
pub fn parse_source(input: &str) -> Result<Source> {
let input = input.trim();
if input.is_empty() {
return Err(SkillsError::msg(
"Empty source. Expected `owner/repo/slug` or a local skill directory.",
));
}
if is_local_path(input) {
let resolved = if Path::new(input).is_absolute() {
PathBuf::from(input)
} else {
std::env::current_dir()?.join(input)
};
return Ok(Source {
ty: SourceType::Local,
raw: input.to_string(),
local_path: Some(resolved),
owner: String::new(),
repo: String::new(),
slug: String::new(),
});
}
if input.starts_with("http://") || input.starts_with("https://") {
return Err(unsupported(
input,
"Full URLs and direct HTTPS downloads are no longer supported; \
pin a version with `--ref <branch|tag|SHA>` if needed.",
));
}
if let Some(s) = parse_github_id(input)? {
return Ok(s);
}
Err(unsupported(input, "Expected `owner/repo/slug`."))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn local_relative_path() {
let s = parse_source("./skills/pdf").unwrap();
assert_eq!(s.ty, SourceType::Local);
assert!(s.local_path.is_some());
}
#[test]
fn local_absolute_path() {
let s = parse_source("/tmp/foo").unwrap();
assert_eq!(s.ty, SourceType::Local);
assert!(s.local_path.is_some());
}
#[test]
fn local_windows_drive() {
let s = parse_source(r"C:\foo\skill").unwrap();
assert_eq!(s.ty, SourceType::Local);
}
#[test]
fn windows_relative_prefixes_are_local_paths() {
for input in [".\\skills\\pdf", "..\\skills\\pdf"] {
let s = parse_source(input).unwrap();
assert_eq!(s.ty, SourceType::Local);
}
}
#[test]
fn github_id_parses_into_three_segments() {
let s = parse_source("acme/skills/pdf").unwrap();
assert_eq!(s.ty, SourceType::Github);
assert_eq!(s.owner, "acme");
assert_eq!(s.repo, "skills");
assert_eq!(s.slug, "pdf");
assert_eq!(s.id(), "acme/skills/pdf");
}
#[test]
fn github_id_strips_git_suffix() {
let s = parse_source("acme/skills.git/pdf").unwrap();
assert_eq!(s.repo, "skills");
}
#[test]
fn slug_keeps_punctuation_verbatim() {
for slug in ["pdf@v2", "c++.net&a&b", "my_skill"] {
let s = parse_source(&format!("acme/skills/{slug}")).unwrap();
assert_eq!(s.slug, slug);
}
}
#[test]
fn bare_owner_repo_is_rejected() {
let e = parse_source("acme/skills").unwrap_err();
assert!(e.to_string().contains("three segments"), "{e}");
assert!(e.to_string().contains("owner/repo/<slug>"), "{e}");
}
#[test]
fn legacy_at_syntax_is_rejected_with_a_migration_hint() {
let e = parse_source("acme/skills@pdf").unwrap_err();
assert!(e.to_string().contains("no longer supported"), "{e}");
assert!(e.to_string().contains("owner/repo/<slug>"), "{e}");
}
#[test]
fn longer_paths_are_rejected() {
let e = parse_source("acme/skills/skills/pdf").unwrap_err();
assert!(e.to_string().contains("exactly three segments"), "{e}");
}
#[test]
fn dot_or_empty_segments_are_rejected() {
assert!(parse_source("acme//pdf").is_err());
assert!(parse_source("acme/skills/.").is_err());
let s = parse_source("../skills/pdf").unwrap();
assert_eq!(s.ty, SourceType::Local);
}
#[test]
fn full_urls_are_rejected() {
let e = parse_source("https://github.com/acme/skills").unwrap_err();
assert!(e.to_string().contains("Full URLs"));
let e = parse_source("https://example.com/skills.zip").unwrap_err();
assert!(e.to_string().contains("Full URLs"));
}
#[test]
fn gitlab_urls_are_rejected() {
let e = parse_source("https://gitlab.com/group/repo/-/tree/main/skills/pdf").unwrap_err();
assert!(e.to_string().contains("Full URLs"));
}
#[test]
fn ssh_git_urls_are_rejected() {
let e = parse_source("git@github.com:acme/skills.git").unwrap_err();
assert!(e.to_string().contains("SSH"));
}
#[test]
fn empty_input_is_rejected() {
assert!(parse_source(" ").is_err());
}
}