use std::io::{IsTerminal, Write};
use std::path::Path;
use skillfile_core::error::SkillfileError;
use skillfile_core::output::Spinner;
use skillfile_sources::http::{HttpClient, UreqClient};
use skillfile_sources::registry::{
fetch_agentskill_github_meta, scrape_github_meta_from_page, search_all, search_registry,
RegistryId, SearchOptions, SearchResponse,
};
use skillfile_sources::resolver::{fetch_github_file, list_repo_skill_entries, GithubFetch};
use super::add::{cmd_add, entry_from_github, GithubEntryArgs};
pub struct SearchConfig<'a> {
pub query: &'a str,
pub limit: usize,
pub min_score: Option<u8>,
pub json: bool,
pub registry: Option<&'a str>,
pub no_interactive: bool,
pub repo_root: &'a Path,
}
pub fn cmd_search(cfg: &SearchConfig<'_>) -> Result<(), SkillfileError> {
let opts = SearchOptions {
limit: cfg.limit,
min_score: cfg.min_score,
};
let spinner = Spinner::new("Searching registries");
let resp = if let Some(name) = cfg.registry {
search_registry(name, cfg.query, &opts)
} else {
search_all(cfg.query, &opts)
};
spinner.finish();
let resp = resp?;
let mut out = std::io::stdout().lock();
if cfg.json {
print_json(&mut out, &resp)?;
} else if !cfg.no_interactive && is_interactive_tty() && !resp.items.is_empty() {
interactive_select(&resp, cfg.repo_root)?;
} else {
print_table(&mut out, &resp, cfg.registry);
}
Ok(())
}
fn is_interactive_tty() -> bool {
std::io::stdin().is_terminal() && std::io::stderr().is_terminal()
}
fn resolve_source_coords(
item: &skillfile_sources::registry::SearchResult,
) -> (Option<String>, Option<String>) {
if item.registry != RegistryId::AgentskillSh
|| item.source_repo.is_some()
|| item.source_path.is_some()
{
return (item.source_repo.clone(), item.source_path.clone());
}
let slug = item
.url
.strip_prefix("https://agentskill.sh/@")
.unwrap_or("");
if slug.is_empty() {
return (None, None);
}
let client = UreqClient::new();
let spinner = Spinner::new("Resolving GitHub coordinates");
let meta = fetch_agentskill_github_meta(&client, slug, &item.name);
spinner.finish();
if let Some(m) = meta {
return (Some(m.source_repo), Some(m.source_path));
}
let spinner = Spinner::new("Fetching source from skill page");
let meta = scrape_github_meta_from_page(&client, slug);
spinner.finish();
match meta {
Some(m) => {
let path = (!m.source_path.is_empty()).then_some(m.source_path);
(Some(m.source_repo), path)
}
None => (None, None),
}
}
fn resolve_owner_repo(source_repo: Option<&str>) -> Result<Option<String>, SkillfileError> {
if let Some(repo) = source_repo {
return Ok(Some(repo.to_string()));
}
println!(" Enter the GitHub repository for this skill.");
prompt_result(
inquire::Text::new("GitHub owner/repo:")
.with_help_message("e.g. owner/repo — check the skill page for the source")
.prompt(),
)
}
fn interactive_select(resp: &SearchResponse, repo_root: &Path) -> Result<(), SkillfileError> {
let selected_idx = super::search_tui::run_tui(&resp.items, resp.total)
.map_err(|e| SkillfileError::Install(format!("TUI error: {e}")))?;
let Some(idx) = selected_idx else {
return Ok(());
};
let item = &resp.items[idx];
let (source_repo, source_path) = resolve_source_coords(item);
println!();
println!(" {}", item.url);
if let Some(repo) = &source_repo {
println!(" source: {repo}");
}
println!();
if source_repo.is_none() && source_path.is_none() && item.registry == RegistryId::AgentskillSh {
eprintln!(
" Could not resolve GitHub coordinates for this skill.\n \
Check the skill page for the source repository, then add manually:\n\n \
skillfile add github skill <owner/repo> <path>"
);
return Ok(());
}
let Some(entity_type) =
prompt_result(inquire::Select::new("Entity type:", vec!["skill", "agent"]).prompt())?
else {
return Ok(());
};
let Some(owner_repo) = resolve_owner_repo(source_repo.as_deref())? else {
return Ok(());
};
let path = if let Some(gh_path) = &source_path {
let entry_path = entry_path_from_github_path(gh_path);
println!(" path: {entry_path}");
entry_path
} else {
let Some(p) = resolve_skill_path(&owner_repo, &item.name, entity_type)? else {
return Ok(());
};
p
};
let entry = entry_from_github(&GithubEntryArgs {
entity_type,
owner_repo: &owner_repo,
path: &path,
ref_: None,
name: None,
});
cmd_add(&entry, repo_root)
}
fn entry_path_from_github_path(github_path: &str) -> String {
let filename = github_path.rsplit('/').next().unwrap_or(github_path);
if filename.eq_ignore_ascii_case("SKILL.md") {
match github_path.rfind('/') {
Some(pos) => github_path[..pos].to_string(),
None => ".".to_string(),
}
} else {
github_path.to_string()
}
}
fn resolve_skill_path(
owner_repo: &str,
skill_name: &str,
entity_type: &str,
) -> Result<Option<String>, SkillfileError> {
let client = UreqClient::new();
let spinner = Spinner::new(&format!("Listing files in {owner_repo}"));
let query = SearchPathQuery {
owner_repo,
skill_name,
entity_type,
};
let (candidates, resolved) = discover_skill_path(&client, &query);
spinner.finish();
if let Some(path) = resolved {
println!(" path: {path}");
return Ok(Some(path));
}
if candidates.is_empty() {
return prompt_result(
inquire::Text::new("Path in repo:")
.with_default(".")
.with_help_message(&format!(
"path to .md file in {owner_repo} (use . for root)"
))
.prompt(),
);
}
if candidates.len() == 1 {
println!(" file: {}", candidates[0]);
return Ok(Some(candidates[0].clone()));
}
prompt_result(inquire::Select::new("Select file:", candidates).prompt())
}
struct SearchPathQuery<'a> {
owner_repo: &'a str,
skill_name: &'a str,
entity_type: &'a str,
}
fn discover_skill_path(
client: &dyn HttpClient,
query: &SearchPathQuery<'_>,
) -> (Vec<String>, Option<String>) {
let mut md_files = list_repo_skill_entries(client, query.owner_repo);
if md_files.is_empty() {
let (canonical_files, canonical_path) =
try_canonical_resolution(client, query.owner_repo, query.skill_name);
if let Some(path) = canonical_path {
return (Vec::new(), Some(path));
}
if !canonical_files.is_empty() {
md_files = canonical_files;
} else if let Some(path) =
probe_common_skill_paths(client, query.owner_repo, query.skill_name)
{
return (Vec::new(), Some(path));
} else {
return (Vec::new(), None);
}
}
if md_files.len() == 1 {
return (Vec::new(), Some(md_files[0].clone()));
}
let ranked = rank_by_name_for_entity(&md_files, query.skill_name, query.entity_type);
if let Some((path, score)) = ranked.first() {
if *score == MatchScore::Exact {
return (Vec::new(), Some(path.clone()));
}
}
let candidates: Vec<String> = ranked.iter().map(|(p, _)| p.clone()).collect();
let list = if candidates.is_empty() {
md_files
} else {
candidates
};
(list, None)
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
enum MatchScore {
Exact,
Contains,
}
fn rank_by_name_for_entity(
entries: &[String],
skill_name: &str,
entity_type: &str,
) -> Vec<(String, MatchScore)> {
let name_key = normalize_skill_key(skill_name);
let mut scored: Vec<(String, MatchScore)> = entries
.iter()
.filter_map(|path| {
let key = entry_name_key(path);
let path_key = normalize_skill_key(path);
if key == name_key {
Some((path.clone(), MatchScore::Exact))
} else if path_key.contains(&name_key) {
Some((path.clone(), MatchScore::Contains))
} else {
None
}
})
.collect();
scored.sort_by_key(|(path, score)| (*score, path_preference(path, entity_type), path.clone()));
scored
}
fn path_preference(path: &str, entity_type: &str) -> u8 {
let preferred_prefix = match entity_type {
"agent" => "agents/",
_ => "skills/",
};
if path.starts_with(preferred_prefix) {
0
} else if path.starts_with('.') {
2
} else {
1
}
}
fn entry_name_key(path: &str) -> String {
let tail = path.rsplit('/').next().unwrap_or(path);
let key = tail.strip_suffix(".md").unwrap_or(tail);
normalize_skill_key(key)
}
fn normalize_skill_key(text: &str) -> String {
let mut out = String::with_capacity(text.len());
let mut last_was_sep = false;
for ch in text.chars() {
if ch.is_ascii_alphanumeric() {
out.push(ch.to_ascii_lowercase());
last_was_sep = false;
} else if !out.is_empty() && !last_was_sep {
out.push('-');
last_was_sep = true;
}
}
if out.ends_with('-') {
out.pop();
}
out
}
fn probe_common_skill_paths(
client: &dyn HttpClient,
owner_repo: &str,
skill_name: &str,
) -> Option<String> {
let slug = normalize_skill_key(skill_name);
if slug.is_empty() {
return None;
}
for ref_ in ["main", "master"] {
let gh = GithubFetch {
client,
owner_repo,
ref_,
};
if let Some(candidate) = common_skill_file_candidates(&slug)
.into_iter()
.find(|candidate| fetch_github_file(&gh, candidate).is_ok())
{
return Some(entry_path_from_github_path(&candidate));
}
}
None
}
fn common_skill_file_candidates(slug: &str) -> [String; 5] {
[
format!("skills/{slug}/SKILL.md"),
format!("{slug}/SKILL.md"),
format!("skills/{slug}.md"),
format!("{slug}.md"),
"SKILL.md".to_string(),
]
}
fn canonical_owner_repo(client: &dyn HttpClient, owner_repo: &str) -> Option<String> {
let url = format!("https://api.github.com/repos/{owner_repo}");
let text = client.get_json(&url).ok()??;
let data: serde_json::Value = serde_json::from_str(&text).ok()?;
let full_name = data["full_name"].as_str()?;
Some(full_name.to_string())
}
fn try_canonical_resolution(
client: &dyn HttpClient,
owner_repo: &str,
skill_name: &str,
) -> (Vec<String>, Option<String>) {
let Some(canonical) = canonical_owner_repo(client, owner_repo) else {
return (Vec::new(), None);
};
let md_files = list_repo_skill_entries(client, &canonical);
let path = if md_files.is_empty() {
probe_common_skill_paths(client, &canonical, skill_name)
} else {
None
};
(md_files, path)
}
#[cfg(debug_assertions)]
pub fn run_search_path_resolution_regression() -> Result<(), SkillfileError> {
let client = FakeSearchPathClient::new();
let query = SearchPathQuery {
owner_repo: "paramchoudhary/resumeskills",
skill_name: "linkedin profile optimizer",
entity_type: "skill",
};
let (_, resolved) = discover_skill_path(&client, &query);
emit_regression_resolution(resolved.as_deref())
}
#[cfg(debug_assertions)]
fn emit_regression_resolution(resolved: Option<&str>) -> Result<(), SkillfileError> {
match resolved {
Some("skills/linkedin-profile-optimizer") => {
println!("skills/linkedin-profile-optimizer");
Ok(())
}
Some(other) => Err(SkillfileError::Install(format!(
"resolved wrong path: {other}"
))),
None => Err(SkillfileError::Install(
"failed to auto-resolve search path".into(),
)),
}
}
#[cfg(debug_assertions)]
struct FakeSearchPathClient {
json: std::collections::HashMap<String, Option<String>>,
}
#[cfg(debug_assertions)]
impl FakeSearchPathClient {
fn new() -> Self {
Self {
json: regression_fixture_json(),
}
}
}
#[cfg(debug_assertions)]
impl HttpClient for FakeSearchPathClient {
fn get_bytes(&self, _url: &str) -> Result<Vec<u8>, SkillfileError> {
Err(SkillfileError::Network("unexpected raw fetch".into()))
}
fn get_json(&self, url: &str) -> Result<Option<String>, SkillfileError> {
Ok(self.json.get(url).cloned().flatten())
}
fn post_json(&self, _url: &str, _body: &str) -> Result<Vec<u8>, SkillfileError> {
Err(SkillfileError::Network("unexpected post".into()))
}
}
#[cfg(debug_assertions)]
fn regression_fixture_json() -> std::collections::HashMap<String, Option<String>> {
use std::collections::HashMap;
let mut json = HashMap::new();
json.insert(
"https://api.github.com/repos/paramchoudhary/resumeskills/git/trees/main?recursive=1"
.to_string(),
None,
);
json.insert(
"https://api.github.com/repos/paramchoudhary/resumeskills/git/trees/master?recursive=1"
.to_string(),
None,
);
json.insert(
"https://api.github.com/repos/paramchoudhary/resumeskills".to_string(),
Some(
serde_json::json!({
"full_name": "Paramchoudhary/ResumeSkills"
})
.to_string(),
),
);
json.insert(
"https://api.github.com/repos/Paramchoudhary/ResumeSkills/git/trees/main?recursive=1"
.to_string(),
Some(
serde_json::json!({
"tree": [
{"type": "blob", "path": ".agents/skills/linkedin-profile-optimizer/SKILL.md"},
{"type": "blob", "path": ".claude/skills/linkedin-profile-optimizer/SKILL.md"},
{"type": "blob", "path": "skills/linkedin-profile-optimizer/SKILL.md"}
]
})
.to_string(),
),
);
json
}
fn prompt_result<T>(result: Result<T, inquire::InquireError>) -> Result<Option<T>, SkillfileError> {
match result {
Ok(val) => Ok(Some(val)),
Err(
inquire::InquireError::OperationCanceled | inquire::InquireError::OperationInterrupted,
) => Ok(None),
Err(e) => Err(SkillfileError::Install(format!("prompt failed: {e}"))),
}
}
fn append_meta_field(meta: &mut String, text: &str) {
if !meta.is_empty() {
meta.push_str(" ");
}
meta.push_str(text);
}
fn build_meta_line(item: &skillfile_sources::registry::SearchResult) -> String {
use std::fmt::Write;
let mut meta = String::new();
if !item.owner.is_empty() {
let _ = write!(meta, "by {}", item.owner);
}
if let Some(stars) = item.stars {
append_meta_field(&mut meta, &format!("{stars} stars"));
}
if let Some(score) = item.security_score {
append_meta_field(&mut meta, &format!("score: {score}/100"));
}
meta
}
pub fn print_table(w: &mut dyn Write, resp: &SearchResponse, single_registry: Option<&str>) {
if resp.items.is_empty() {
let _ = writeln!(w, "No results found.");
return;
}
for item in &resp.items {
let desc = item.description.as_deref().unwrap_or("");
if single_registry.is_some() {
let _ = writeln!(w, " {:<24}{desc}", item.name);
} else {
let _ = writeln!(
w,
" {:<24}{:<16}{desc}",
item.name,
format!("[{}]", item.registry),
);
}
let meta = build_meta_line(item);
if !meta.is_empty() {
let _ = writeln!(w, " {:<24}{meta}", "");
}
let _ = writeln!(w, " {:<24}{}", "", item.url);
let _ = writeln!(w);
}
let n = resp.items.len();
let total = resp.total;
let word = if n == 1 { "result" } else { "results" };
let source_label = match single_registry {
Some(name) => format!("via {name}"),
None => "across all registries".to_string(),
};
if total > n {
let _ = writeln!(w, "{n} {word} shown ({total} total, {source_label})");
} else {
let _ = writeln!(w, "{n} {word} ({source_label})");
}
}
pub fn print_json(w: &mut dyn Write, resp: &SearchResponse) -> Result<(), SkillfileError> {
let json = serde_json::to_string_pretty(resp)
.map_err(|e| SkillfileError::Install(format!("failed to serialize search results: {e}")))?;
let _ = writeln!(w, "{json}");
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use std::collections::HashMap;
use skillfile_sources::registry::SearchResult;
struct SearchPathClient {
json: HashMap<String, Option<String>>,
bytes: HashMap<String, Vec<u8>>,
}
impl SearchPathClient {
fn new() -> Self {
Self {
json: HashMap::new(),
bytes: HashMap::new(),
}
}
fn with_json(mut self, url: &str, body: Option<serde_json::Value>) -> Self {
self.json
.insert(url.to_string(), body.map(|value| value.to_string()));
self
}
fn with_bytes(mut self, url: &str, body: &[u8]) -> Self {
self.bytes.insert(url.to_string(), body.to_vec());
self
}
}
impl HttpClient for SearchPathClient {
fn get_bytes(&self, url: &str) -> Result<Vec<u8>, SkillfileError> {
match self.bytes.get(url) {
Some(bytes) => Ok(bytes.clone()),
None => Err(SkillfileError::Network(format!(
"unexpected raw fetch in test: {url}"
))),
}
}
fn get_json(&self, url: &str) -> Result<Option<String>, SkillfileError> {
Ok(self.json.get(url).cloned().flatten())
}
fn post_json(&self, _url: &str, _body: &str) -> Result<Vec<u8>, SkillfileError> {
Err(SkillfileError::Network("unexpected post".into()))
}
}
#[test]
fn normalize_skill_key_collapses_separators() {
assert_eq!(
normalize_skill_key("LinkedIn Profile_Optimizer"),
"linkedin-profile-optimizer"
);
}
#[test]
fn rank_by_name_matches_space_separated_skill_name_to_hyphenated_path() {
let entries = vec![
"skills/linkedin-profile-optimizer".to_string(),
"skills/resume-tailor".to_string(),
];
let ranked = rank_by_name_for_entity(&entries, "linkedin profile optimizer", "skill");
assert_eq!(
ranked.first(),
Some(&(
"skills/linkedin-profile-optimizer".to_string(),
MatchScore::Exact,
))
);
}
#[test]
fn rank_by_name_prefers_skills_dir_over_hidden_mirrors_for_skill() {
let entries = vec![
".agents/skills/linkedin-profile-optimizer".to_string(),
"skills/linkedin-profile-optimizer".to_string(),
];
let ranked = rank_by_name_for_entity(&entries, "linkedin profile optimizer", "skill");
assert_eq!(
ranked.first(),
Some(&(
"skills/linkedin-profile-optimizer".to_string(),
MatchScore::Exact,
))
);
}
#[test]
fn rank_by_name_prefers_agents_dir_over_hidden_mirrors_for_agent() {
let entries = vec![
".claude/agents/code-reviewer.md".to_string(),
"agents/code-reviewer.md".to_string(),
];
let ranked = rank_by_name_for_entity(&entries, "code reviewer", "agent");
assert_eq!(
ranked.first(),
Some(&("agents/code-reviewer.md".to_string(), MatchScore::Exact))
);
}
#[test]
fn common_skill_file_candidates_cover_standard_layouts() {
let candidates = common_skill_file_candidates("linkedin-profile-optimizer");
assert_eq!(candidates[0], "skills/linkedin-profile-optimizer/SKILL.md");
assert_eq!(candidates[4], "SKILL.md");
}
#[test]
fn canonical_owner_repo_parses_full_name() {
let client = SearchPathClient::new().with_json(
"https://api.github.com/repos/paramchoudhary/resumeskills",
Some(serde_json::json!({
"full_name": "Paramchoudhary/ResumeSkills"
})),
);
assert_eq!(
canonical_owner_repo(&client, "paramchoudhary/resumeskills").as_deref(),
Some("Paramchoudhary/ResumeSkills")
);
}
#[test]
fn discover_skill_path_uses_canonical_repo_when_original_name_is_stale() {
let client = SearchPathClient::new()
.with_json(
"https://api.github.com/repos/paramchoudhary/resumeskills/git/trees/main?recursive=1",
None,
)
.with_json(
"https://api.github.com/repos/paramchoudhary/resumeskills/git/trees/master?recursive=1",
None,
)
.with_json(
"https://api.github.com/repos/paramchoudhary/resumeskills",
Some(serde_json::json!({
"full_name": "Paramchoudhary/ResumeSkills"
})),
)
.with_json(
"https://api.github.com/repos/Paramchoudhary/ResumeSkills/git/trees/main?recursive=1",
Some(serde_json::json!({
"tree": [
{"type": "blob", "path": ".agents/skills/linkedin-profile-optimizer/SKILL.md"},
{"type": "blob", "path": "skills/linkedin-profile-optimizer/SKILL.md"}
]
})),
);
let query = SearchPathQuery {
owner_repo: "paramchoudhary/resumeskills",
skill_name: "linkedin profile optimizer",
entity_type: "skill",
};
let (candidates, resolved) = discover_skill_path(&client, &query);
assert!(candidates.is_empty());
assert_eq!(
resolved.as_deref(),
Some("skills/linkedin-profile-optimizer")
);
}
#[test]
fn discover_skill_path_probes_common_layout_when_tree_api_returns_empty() {
let client = SearchPathClient::new()
.with_json(
"https://api.github.com/repos/paramchoudhary/resumeskills/git/trees/main?recursive=1",
None,
)
.with_json(
"https://api.github.com/repos/paramchoudhary/resumeskills/git/trees/master?recursive=1",
None,
)
.with_json(
"https://api.github.com/repos/paramchoudhary/resumeskills",
Some(serde_json::json!({
"full_name": "Paramchoudhary/ResumeSkills"
})),
)
.with_json(
"https://api.github.com/repos/Paramchoudhary/ResumeSkills/git/trees/main?recursive=1",
None,
)
.with_json(
"https://api.github.com/repos/Paramchoudhary/ResumeSkills/git/trees/master?recursive=1",
None,
)
.with_bytes(
"https://raw.githubusercontent.com/paramchoudhary/resumeskills/main/skills/linkedin-profile-optimizer/SKILL.md",
b"# skill",
);
let query = SearchPathQuery {
owner_repo: "paramchoudhary/resumeskills",
skill_name: "linkedin profile optimizer",
entity_type: "skill",
};
let (candidates, resolved) = discover_skill_path(&client, &query);
assert!(candidates.is_empty());
assert_eq!(
resolved.as_deref(),
Some("skills/linkedin-profile-optimizer")
);
}
#[test]
fn discover_skill_path_returns_filtered_candidates_without_exact_match() {
let client = SearchPathClient::new().with_json(
"https://api.github.com/repos/example/repo/git/trees/main?recursive=1",
Some(serde_json::json!({
"tree": [
{"type": "blob", "path": "skills/linkedin-profile-helper/SKILL.md"},
{"type": "blob", "path": "skills/linkedin-profile-writer/SKILL.md"},
{"type": "blob", "path": "skills/resume-tailor/SKILL.md"}
]
})),
);
let query = SearchPathQuery {
owner_repo: "example/repo",
skill_name: "linkedin profile",
entity_type: "skill",
};
let (candidates, resolved) = discover_skill_path(&client, &query);
assert!(resolved.is_none());
assert_eq!(
candidates,
vec![
"skills/linkedin-profile-helper".to_string(),
"skills/linkedin-profile-writer".to_string(),
]
);
}
#[test]
fn prompt_result_ok_returns_some() {
let result: Result<String, inquire::InquireError> = Ok("test".to_string());
let value = prompt_result(result).unwrap();
assert_eq!(value, Some("test".to_string()));
}
#[test]
fn prompt_result_canceled_returns_none() {
let result: Result<String, inquire::InquireError> =
Err(inquire::InquireError::OperationCanceled);
let value = prompt_result(result).unwrap();
assert!(value.is_none());
}
#[test]
fn prompt_result_interrupted_returns_none() {
let result: Result<String, inquire::InquireError> =
Err(inquire::InquireError::OperationInterrupted);
let value = prompt_result(result).unwrap();
assert!(value.is_none());
}
#[test]
fn prompt_result_io_error_returns_err() {
let io_err = std::io::Error::other("test error");
let result: Result<String, inquire::InquireError> = Err(inquire::InquireError::IO(io_err));
let err = prompt_result(result).unwrap_err();
assert!(err.to_string().contains("prompt failed"));
}
#[test]
fn entry_path_root_skill_md() {
assert_eq!(entry_path_from_github_path("SKILL.md"), ".");
}
#[test]
fn entry_path_root_skill_md_case_insensitive() {
assert_eq!(entry_path_from_github_path("skill.md"), ".");
assert_eq!(entry_path_from_github_path("Skill.md"), ".");
}
#[test]
fn entry_path_nested_skill_md_becomes_dir() {
assert_eq!(
entry_path_from_github_path("skills/kubernetes-specialist/SKILL.md"),
"skills/kubernetes-specialist"
);
}
#[test]
fn entry_path_deeply_nested_skill_md() {
assert_eq!(
entry_path_from_github_path("skills/arnarsson/fzf-fuzzy-finder/SKILL.md"),
"skills/arnarsson/fzf-fuzzy-finder"
);
}
#[test]
fn entry_path_regular_md_stays_as_is() {
assert_eq!(
entry_path_from_github_path("agents/code-reviewer.md"),
"agents/code-reviewer.md"
);
}
#[test]
fn entry_path_non_skill_md_stays_as_is() {
assert_eq!(
entry_path_from_github_path("skills/docker/helper.md"),
"skills/docker/helper.md"
);
}
fn paths(strs: &[&str]) -> Vec<String> {
strs.iter().map(std::string::ToString::to_string).collect()
}
#[test]
fn rank_exact_dir_entry() {
let entries = paths(&["skills/kubernetes-specialist", "skills/docker-helper"]);
let ranked = rank_by_name_for_entity(&entries, "kubernetes-specialist", "skill");
assert_eq!(ranked.len(), 1);
assert_eq!(ranked[0].0, "skills/kubernetes-specialist");
assert_eq!(ranked[0].1, MatchScore::Exact);
}
#[test]
fn rank_exact_single_file() {
let entries = paths(&["skills/kubernetes-specialist.md", "skills/docker-helper.md"]);
let ranked = rank_by_name_for_entity(&entries, "kubernetes-specialist", "skill");
assert_eq!(ranked.len(), 1);
assert_eq!(ranked[0].0, "skills/kubernetes-specialist.md");
assert_eq!(ranked[0].1, MatchScore::Exact);
}
#[test]
fn rank_exact_case_insensitive() {
let entries = paths(&["skills/Kubernetes-Specialist", "skills/other"]);
let ranked = rank_by_name_for_entity(&entries, "kubernetes-specialist", "skill");
assert_eq!(ranked.len(), 1);
assert_eq!(ranked[0].1, MatchScore::Exact);
}
#[test]
fn rank_contains_match() {
let entries = paths(&["skills/advanced-kubernetes-specialist-v2", "skills/docker"]);
let ranked = rank_by_name_for_entity(&entries, "kubernetes-specialist", "skill");
assert_eq!(ranked.len(), 1);
assert_eq!(ranked[0].0, "skills/advanced-kubernetes-specialist-v2");
assert_eq!(ranked[0].1, MatchScore::Contains);
}
#[test]
fn rank_exact_beats_contains() {
let entries = paths(&[
"skills/extra-kubernetes-specialist-stuff",
"skills/kubernetes-specialist",
]);
let ranked = rank_by_name_for_entity(&entries, "kubernetes-specialist", "skill");
assert_eq!(ranked.len(), 2);
assert_eq!(ranked[0].1, MatchScore::Exact);
assert_eq!(ranked[0].0, "skills/kubernetes-specialist");
assert_eq!(ranked[1].1, MatchScore::Contains);
}
#[test]
fn rank_no_matches_returns_empty() {
let entries = paths(&["skills/docker", "skills/python", "skills/rust.md"]);
let ranked = rank_by_name_for_entity(&entries, "kubernetes-specialist", "skill");
assert!(ranked.is_empty());
}
#[test]
fn rank_dot_entry_never_matches() {
let entries = paths(&["."]);
let ranked = rank_by_name_for_entity(&entries, "some-skill", "skill");
assert!(ranked.is_empty());
}
#[test]
fn rank_empty_entries_returns_empty() {
let ranked = rank_by_name_for_entity(&[], "anything", "skill");
assert!(ranked.is_empty());
}
#[test]
fn rank_contains_matches_parent_dir() {
let entries = paths(&["kubernetes-specialist/references", "unrelated/thing.md"]);
let ranked = rank_by_name_for_entity(&entries, "kubernetes-specialist", "skill");
assert_eq!(ranked.len(), 1);
assert_eq!(ranked[0].1, MatchScore::Contains);
}
#[test]
fn rank_multi_skill_repo_finds_right_one() {
let entries = paths(&[
"skills/kubernetes-specialist",
"skills/docker-helper",
"skills/python-pro",
"skills/code-reviewer.md",
]);
let ranked = rank_by_name_for_entity(&entries, "kubernetes-specialist", "skill");
assert_eq!(ranked.len(), 1);
assert_eq!(ranked[0].0, "skills/kubernetes-specialist");
assert_eq!(ranked[0].1, MatchScore::Exact);
}
#[cfg(debug_assertions)]
#[test]
fn emit_regression_resolution_errors_on_wrong_path() {
let err = emit_regression_resolution(Some("skills/wrong")).unwrap_err();
assert!(err.to_string().contains("resolved wrong path"));
}
#[cfg(debug_assertions)]
#[test]
fn emit_regression_resolution_errors_when_path_is_missing() {
let err = emit_regression_resolution(None).unwrap_err();
assert!(err.to_string().contains("failed to auto-resolve"));
}
fn sample_response() -> SearchResponse {
SearchResponse {
total: 2,
items: vec![
SearchResult {
name: "code-reviewer".to_string(),
owner: "alice".to_string(),
description: Some("Review code changes".to_string()),
security_score: Some(92),
stars: Some(150),
url: "https://agentskill.sh/@alice/code-reviewer".to_string(),
registry: RegistryId::AgentskillSh,
source_repo: Some("alice/code-reviewer".to_string()),
source_path: None,
},
SearchResult {
name: "pr-review".to_string(),
owner: "bob".to_string(),
description: None,
security_score: None,
stars: None,
url: "https://agentskill.sh/@bob/pr-review".to_string(),
registry: RegistryId::AgentskillSh,
source_repo: Some("bob/pr-review".to_string()),
source_path: None,
},
],
}
}
fn multi_registry_response() -> SearchResponse {
SearchResponse {
total: 3,
items: vec![
SearchResult {
name: "code-reviewer".to_string(),
owner: "alice".to_string(),
description: Some("Review code changes".to_string()),
security_score: Some(92),
stars: Some(150),
url: "https://agentskill.sh/@alice/code-reviewer".to_string(),
registry: RegistryId::AgentskillSh,
source_repo: Some("alice/code-reviewer".to_string()),
source_path: None,
},
SearchResult {
name: "docker-helper".to_string(),
owner: "dockerfan".to_string(),
description: None,
security_score: None,
stars: Some(500),
url: "https://skills.sh/dockerfan/docker-helper/docker-helper".to_string(),
registry: RegistryId::SkillsSh,
source_repo: Some("dockerfan/docker-helper".to_string()),
source_path: None,
},
SearchResult {
name: "testing-pro".to_string(),
owner: "testmaster".to_string(),
description: Some("Advanced testing".to_string()),
security_score: Some(88),
stars: Some(75),
url: "https://www.skillhub.club/skills/testing-pro".to_string(),
registry: RegistryId::SkillhubClub,
source_repo: None,
source_path: None,
},
],
}
}
#[test]
fn table_single_registry_shows_via_label() {
let resp = sample_response();
let mut buf = Vec::new();
print_table(&mut buf, &resp, Some("agentskill.sh"));
let out = String::from_utf8(buf).unwrap();
assert!(out.contains("via agentskill.sh"));
assert!(out.contains("code-reviewer"));
assert!(out.contains("Review code changes"));
assert!(out.contains("by alice"));
assert!(out.contains("150 stars"));
assert!(out.contains("score: 92/100"));
}
#[test]
fn table_single_registry_omits_registry_tag() {
let resp = sample_response();
let mut buf = Vec::new();
print_table(&mut buf, &resp, Some("agentskill.sh"));
let out = String::from_utf8(buf).unwrap();
assert!(!out.contains("[agentskill.sh]"));
}
#[test]
fn table_multi_registry_shows_tags_and_label() {
let resp = multi_registry_response();
let mut buf = Vec::new();
print_table(&mut buf, &resp, None);
let out = String::from_utf8(buf).unwrap();
assert!(out.contains("[agentskill.sh]"));
assert!(out.contains("[skills.sh]"));
assert!(out.contains("[skillhub.club]"));
assert!(out.contains("across all registries"));
}
#[test]
fn table_empty_results() {
let resp = SearchResponse {
total: 0,
items: vec![],
};
let mut buf = Vec::new();
print_table(&mut buf, &resp, None);
let out = String::from_utf8(buf).unwrap();
assert!(out.contains("No results found."));
}
#[test]
fn table_shows_total_when_more() {
let resp = SearchResponse {
total: 50,
items: vec![SearchResult {
name: "test".to_string(),
owner: "owner".to_string(),
description: Some("A test skill".to_string()),
security_score: Some(80),
stars: Some(10),
url: "https://agentskill.sh/@owner/test".to_string(),
registry: RegistryId::AgentskillSh,
source_repo: None,
source_path: None,
}],
};
let mut buf = Vec::new();
print_table(&mut buf, &resp, Some("agentskill.sh"));
let out = String::from_utf8(buf).unwrap();
assert!(out.contains("1 result shown (50 total, via agentskill.sh)"));
}
#[test]
fn table_result_without_optional_fields() {
let resp = SearchResponse {
total: 1,
items: vec![SearchResult {
name: "minimal".to_string(),
owner: String::new(),
description: None,
security_score: None,
stars: None,
url: "https://agentskill.sh/@x/minimal".to_string(),
registry: RegistryId::AgentskillSh,
source_repo: None,
source_path: None,
}],
};
let mut buf = Vec::new();
print_table(&mut buf, &resp, Some("agentskill.sh"));
let out = String::from_utf8(buf).unwrap();
assert!(out.contains("minimal"));
assert!(out.contains("agentskill.sh/@x/minimal"));
assert!(!out.contains("by "));
assert!(!out.contains("stars"));
assert!(!out.contains("score:"));
}
#[test]
fn json_outputs_valid_json_with_registry() {
let resp = sample_response();
let mut buf = Vec::new();
print_json(&mut buf, &resp).unwrap();
let out = String::from_utf8(buf).unwrap();
let parsed: serde_json::Value = serde_json::from_str(&out).unwrap();
assert!(parsed["items"].is_array());
assert!(parsed["total"].is_number());
for item in parsed["items"].as_array().unwrap() {
assert!(item["registry"].is_string());
}
}
#[test]
fn json_empty() {
let resp = SearchResponse {
total: 0,
items: vec![],
};
let mut buf = Vec::new();
print_json(&mut buf, &resp).unwrap();
let out = String::from_utf8(buf).unwrap();
let parsed: serde_json::Value = serde_json::from_str(&out).unwrap();
assert_eq!(parsed["total"], 0);
assert!(parsed["items"].as_array().unwrap().is_empty());
}
#[test]
fn json_multi_registry_includes_all_tags() {
let resp = multi_registry_response();
let mut buf = Vec::new();
print_json(&mut buf, &resp).unwrap();
let out = String::from_utf8(buf).unwrap();
assert!(out.contains("\"registry\": \"agentskill.sh\""));
assert!(out.contains("\"registry\": \"skills.sh\""));
assert!(out.contains("\"registry\": \"skillhub.club\""));
}
}