use std::collections::{HashMap, HashSet};
use std::path::PathBuf;
use serde::{Deserialize, Serialize};
use algocline_core::{AppDir, PkgEntity};
use super::list_opts::{
apply_sort_by_value, matches_filter, parse_sort, project_fields, resolve_fields, ListOpts,
HUB_SEARCH_FULL, HUB_SEARCH_SUMMARY,
};
use super::manifest;
use super::resolve::AUTO_INSTALL_SOURCES;
use super::source::PackageSource;
use super::AppService;
const CACHE_TTL_SECS: u64 = 3600;
const HTTP_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(30);
#[derive(Debug, Clone, Serialize, Deserialize)]
pub(crate) struct HubIndex {
pub schema_version: String,
#[serde(default)]
pub updated_at: String,
#[serde(default)]
pub packages: Vec<IndexEntry>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub(crate) struct IndexEntry {
#[serde(flatten)]
pub entity: PkgEntity,
#[serde(default)]
pub source: PackageSource,
#[serde(default)]
pub card_count: usize,
#[serde(default)]
pub best_card: Option<BestCard>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub(crate) struct BestCard {
pub card_id: String,
#[serde(default)]
pub model: String,
#[serde(default)]
pub pass_rate: f64,
#[serde(default)]
pub scenario: String,
}
#[derive(Debug, Clone, Serialize)]
struct SearchResult {
#[serde(flatten, serialize_with = "serialize_entity_without_docstring")]
entity: PkgEntity,
source: PackageSource,
installed: bool,
card_count: usize,
best_card: Option<BestCard>,
#[serde(skip_serializing_if = "Option::is_none")]
docstring_matched: Option<bool>,
}
fn serialize_entity_without_docstring<S>(entity: &PkgEntity, ser: S) -> Result<S::Ok, S::Error>
where
S: serde::Serializer,
{
use serde::ser::SerializeMap;
let mut map = ser.serialize_map(Some(4))?;
map.serialize_entry("name", &entity.name)?;
map.serialize_entry("version", &entity.version)?;
map.serialize_entry("description", &entity.description)?;
map.serialize_entry("category", &entity.category)?;
map.end()
}
impl SearchResult {
fn to_value_with_optional_docstring(&self, include_docstring: bool) -> serde_json::Value {
let mut v = serde_json::to_value(self).unwrap_or(serde_json::Value::Null);
if include_docstring {
if let serde_json::Value::Object(ref mut map) = v {
let doc = self.entity.docstring.clone().unwrap_or_default();
map.insert("docstring".to_string(), serde_json::Value::String(doc));
}
}
v
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub(crate) struct RegistryEntry {
pub source: String,
pub origin: String,
pub added_at: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub(crate) struct HubRegistries {
pub registries: Vec<RegistryEntry>,
}
fn registries_path(app_dir: &AppDir) -> PathBuf {
app_dir.hub_registries_json()
}
fn load_registries(app_dir: &AppDir) -> HubRegistries {
let path = registries_path(app_dir);
if !path.exists() {
return HubRegistries::default();
}
std::fs::read_to_string(&path)
.ok()
.and_then(|c| serde_json::from_str(&c).ok())
.unwrap_or_default()
}
pub(crate) fn register_source(app_dir: &AppDir, source: &str, origin: &str) -> Result<(), String> {
let normalized = source.trim_end_matches('/').to_string();
if normalized.is_empty() {
return Ok(());
}
if normalized.starts_with('/') || normalized.starts_with('.') {
return Ok(());
}
let path = registries_path(app_dir);
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent).map_err(|e| {
format!(
"failed to create hub registries dir {}: {e}",
parent.display()
)
})?;
}
let mut reg = load_registries(app_dir);
if reg
.registries
.iter()
.any(|e| e.source.trim_end_matches('/') == normalized)
{
return Ok(());
}
reg.registries.push(RegistryEntry {
source: normalized,
origin: origin.to_string(),
added_at: manifest::now_iso8601(),
});
let json = serde_json::to_string_pretty(®)
.map_err(|e| format!("failed to serialize hub registries: {e}"))?;
let tmp_path = path.with_extension("json.tmp");
std::fs::write(&tmp_path, &json).map_err(|e| {
format!(
"failed to write hub registries tmp {}: {e}",
tmp_path.display()
)
})?;
std::fs::rename(&tmp_path, &path).map_err(|e| {
let _ = std::fs::remove_file(&tmp_path);
format!(
"failed to atomically rename hub registries onto {}: {e}",
path.display()
)
})
}
fn collection_url_from_config(app_dir: &AppDir) -> Option<String> {
let path = app_dir.config_toml();
let content = std::fs::read_to_string(&path).ok()?;
let doc: toml_edit::DocumentMut = content.parse().ok()?;
let url = doc
.get("hub")?
.get("collection_url")?
.as_str()?
.trim()
.to_string();
if url.is_empty() {
None
} else {
Some(url)
}
}
fn repo_to_index_url(repo_url: &str) -> Option<String> {
let trimmed = repo_url.trim_end_matches('/').trim_end_matches(".git");
if let Some(path) = trimmed.strip_prefix("https://github.com/") {
let parts: Vec<&str> = path.splitn(3, '/').collect();
if parts.len() >= 2 {
return Some(format!(
"https://raw.githubusercontent.com/{}/{}/main/hub_index.json",
parts[0], parts[1]
));
}
}
if trimmed.ends_with(".json") {
Some(trimmed.to_string())
} else {
None
}
}
fn discover_index_urls(app_dir: &AppDir) -> Result<Vec<String>, String> {
let mut index_urls: Vec<String> = Vec::new();
if let Some(url) = collection_url_from_config(app_dir) {
index_urls.push(url);
}
let mut repo_urls: HashSet<String> = HashSet::new();
let reg = load_registries(app_dir);
for entry in ®.registries {
let normalized = entry.source.trim_end_matches('/').to_string();
if !normalized.is_empty() {
repo_urls.insert(normalized);
}
}
let m = manifest::load_manifest(app_dir)?;
for entry in m.packages.values() {
if let Some(url) = entry.source.git_url() {
let normalized = url.trim_end_matches('/').to_string();
if !normalized.is_empty() {
repo_urls.insert(normalized);
}
}
}
for url in AUTO_INSTALL_SOURCES {
repo_urls.insert(url.to_string());
}
let existing: HashSet<String> = index_urls.iter().cloned().collect();
let mut derived: Vec<String> = repo_urls
.iter()
.filter_map(|url| repo_to_index_url(url))
.filter(|url| !existing.contains(url))
.collect();
derived.sort();
derived.dedup();
index_urls.extend(derived);
Ok(index_urls)
}
fn cache_dir(app_dir: &AppDir) -> PathBuf {
app_dir.hub_cache_dir()
}
fn cache_key(url: &str) -> String {
let mut h: u64 = 0xcbf2_9ce4_8422_2325; for b in url.as_bytes() {
h ^= *b as u64;
h = h.wrapping_mul(0x0100_0000_01b3); }
format!("{h:016x}")
}
fn load_cached(app_dir: &AppDir, url: &str) -> Option<HubIndex> {
let dir = cache_dir(app_dir);
let path = dir.join(format!("{}.json", cache_key(url)));
if !path.exists() {
return None;
}
let metadata = std::fs::metadata(&path).ok()?;
let age = metadata.modified().ok()?.elapsed().ok()?;
if age.as_secs() > CACHE_TTL_SECS {
return None;
}
let content = std::fs::read_to_string(&path).ok()?;
serde_json::from_str(&content).ok()
}
fn save_cached(app_dir: &AppDir, url: &str, index: &HubIndex) -> Result<(), String> {
let dir = cache_dir(app_dir);
std::fs::create_dir_all(&dir)
.map_err(|e| format!("failed to create hub cache dir {}: {e}", dir.display()))?;
let path = dir.join(format!("{}.json", cache_key(url)));
let json = serde_json::to_string_pretty(index)
.map_err(|e| format!("failed to serialize hub cache: {e}"))?;
std::fs::write(&path, json)
.map_err(|e| format!("failed to write hub cache {}: {e}", path.display()))
}
fn fetch_one(app_dir: &AppDir, url: &str) -> Result<(HubIndex, Option<String>), String> {
if let Some(cached) = load_cached(app_dir, url) {
return Ok((cached, None));
}
let agent = ureq::Agent::new_with_config(
ureq::config::Config::builder()
.timeout_global(Some(HTTP_TIMEOUT))
.build(),
);
let body: String = agent
.get(url)
.call()
.map_err(|e| format!("Failed to fetch {url}: {e}"))?
.body_mut()
.read_to_string()
.map_err(|e| format!("Failed to read response from {url}: {e}"))?;
let index: HubIndex = serde_json::from_str(&body)
.map_err(|e| format!("Failed to parse index from {url}: {e}"))?;
let cache_warning = save_cached(app_dir, url, &index)
.err()
.map(|e| format!("hub cache write for {url}: {e}"));
Ok((index, cache_warning))
}
fn fetch_remote_indices(app_dir: &AppDir) -> Result<(HubIndex, Vec<String>), String> {
let urls = discover_index_urls(app_dir)?;
let mut all_packages: Vec<IndexEntry> = Vec::new();
let mut seen_names: HashSet<String> = HashSet::new();
let mut warnings: Vec<String> = Vec::new();
for url in &urls {
match fetch_one(app_dir, url) {
Ok((index, cache_warning)) => {
for entry in index.packages {
if seen_names.insert(entry.entity.name.clone()) {
all_packages.push(entry);
}
}
if let Some(w) = cache_warning {
warnings.push(w);
}
}
Err(e) => {
warnings.push(e);
}
}
}
if all_packages.is_empty() && !warnings.is_empty() {
warnings.insert(
0,
"all remote indices unavailable, showing local packages only".to_string(),
);
}
let merged = HubIndex {
schema_version: "hub_index/v0".into(),
updated_at: String::new(),
packages: all_packages,
};
Ok((merged, warnings))
}
fn installed_packages(app_dir: &AppDir) -> Result<HashMap<String, Option<String>>, String> {
let mut map = HashMap::new();
let m = manifest::load_manifest(app_dir)?;
for (name, entry) in &m.packages {
map.insert(name.clone(), entry.version.clone());
}
let pkg_dir = app_dir.packages_dir();
if let Ok(entries) = std::fs::read_dir(&pkg_dir) {
for entry in entries.flatten() {
if entry.path().is_dir() {
if let Some(name) = entry.file_name().to_str() {
map.entry(name.to_string()).or_insert(None);
}
}
}
}
Ok(map)
}
fn local_card_counts(app_dir: &AppDir) -> HashMap<String, usize> {
let mut map = HashMap::new();
let cards_dir = app_dir.cards_dir();
let entries = match std::fs::read_dir(&cards_dir) {
Ok(e) => e,
Err(_) => return map,
};
for entry in entries.flatten() {
if !entry.path().is_dir() {
continue;
}
let pkg = match entry.file_name().to_str() {
Some(n) => n.to_string(),
None => continue,
};
let count = std::fs::read_dir(entry.path())
.map(|es| {
es.flatten()
.filter(|e| e.path().extension().is_some_and(|ext| ext == "toml"))
.count()
})
.unwrap_or(0);
if count > 0 {
map.insert(pkg, count);
}
}
map
}
fn count_evals_for_pkg(app_dir: &AppDir, pkg: &str) -> usize {
let evals_dir = app_dir.evals_dir();
let entries = match std::fs::read_dir(&evals_dir) {
Ok(e) => e,
Err(_) => return 0,
};
let mut meta_stems: HashSet<String> = HashSet::new();
let mut meta_matches: usize = 0;
let mut non_meta_paths: Vec<(PathBuf, String)> = Vec::new();
for entry in entries.flatten() {
let path = entry.path();
let name = match path.file_name().and_then(|n| n.to_str()) {
Some(n) => n.to_string(),
None => continue,
};
if name.ends_with(".meta.json") {
let stem = name.trim_end_matches(".meta.json").to_string();
meta_stems.insert(stem);
if let Ok(content) = std::fs::read_to_string(&path) {
if let Ok(val) = serde_json::from_str::<serde_json::Value>(&content) {
if val.get("strategy").and_then(|s| s.as_str()) == Some(pkg) {
meta_matches += 1;
}
}
}
continue;
}
if !name.ends_with(".json") || name.starts_with("compare_") {
continue;
}
let stem = path
.file_stem()
.and_then(|s| s.to_str())
.unwrap_or("")
.to_string();
non_meta_paths.push((path, stem));
}
let fallback_matches = non_meta_paths
.iter()
.filter(|(_, stem)| !meta_stems.contains(stem))
.filter(|(path, _)| {
std::fs::read_to_string(path)
.ok()
.and_then(|c| serde_json::from_str::<serde_json::Value>(&c).ok())
.and_then(|v| v.get("strategy")?.as_str().map(|s| s == pkg))
.unwrap_or(false)
})
.count();
meta_matches + fallback_matches
}
fn merge(app_dir: &AppDir, remote: &HubIndex) -> Result<Vec<SearchResult>, String> {
let installed = installed_packages(app_dir)?;
let card_counts = local_card_counts(app_dir);
let pkg_dir: Option<PathBuf> = Some(app_dir.packages_dir());
let mut seen: HashSet<String> = HashSet::new();
let mut results: Vec<SearchResult> = Vec::new();
for entry in &remote.packages {
let pkg_name = &entry.entity.name;
let is_installed = installed.contains_key(pkg_name);
let local_cards = card_counts.get(pkg_name).copied().unwrap_or(0);
let docstring = if entry.entity.docstring.as_deref().unwrap_or("").is_empty()
&& is_installed
{
pkg_dir
.as_ref()
.and_then(|d| PkgEntity::parse_from_init_lua(&d.join(pkg_name).join("init.lua")))
.and_then(|e| e.docstring)
} else {
entry.entity.docstring.clone()
};
seen.insert(pkg_name.clone());
let mut merged_entity = entry.entity.clone();
merged_entity.docstring = docstring;
results.push(SearchResult {
entity: merged_entity,
source: entry.source.clone(),
installed: is_installed,
card_count: if is_installed && local_cards > entry.card_count {
local_cards
} else {
entry.card_count
},
best_card: entry.best_card.clone(),
docstring_matched: None,
});
}
for (name, version) in &installed {
if seen.contains(name) {
continue;
}
let parsed_entity = pkg_dir
.as_ref()
.and_then(|d| PkgEntity::parse_from_init_lua(&d.join(name).join("init.lua")));
let entity = parsed_entity.unwrap_or(PkgEntity {
name: name.clone(),
version: version.clone(),
description: None,
category: None,
docstring: None,
});
results.push(SearchResult {
entity,
source: PackageSource::Unknown,
installed: true,
card_count: card_counts.get(name).copied().unwrap_or(0),
best_card: None,
docstring_matched: None,
});
}
Ok(results)
}
fn matches_query(result: &SearchResult, query: &str) -> bool {
let q = query.to_lowercase();
let pkg = &result.entity;
let empty = String::new();
pkg.name.to_lowercase().contains(&q)
|| pkg
.description
.as_ref()
.unwrap_or(&empty)
.to_lowercase()
.contains(&q)
|| pkg
.category
.as_ref()
.unwrap_or(&empty)
.to_lowercase()
.contains(&q)
|| pkg
.docstring
.as_ref()
.unwrap_or(&empty)
.to_lowercase()
.contains(&q)
}
fn build_index(app_dir: &AppDir, source_dir: Option<&std::path::Path>) -> Result<HubIndex, String> {
let empty = || HubIndex {
schema_version: "hub_index/v0".into(),
updated_at: super::manifest::now_iso8601(),
packages: Vec::new(),
};
let pkg_dir = match source_dir {
Some(d) => d.to_path_buf(),
None => app_dir.packages_dir(),
};
let use_local_state = source_dir.is_none();
let card_counts = if use_local_state {
local_card_counts(app_dir)
} else {
HashMap::new()
};
let manifest = if use_local_state {
manifest::load_manifest(app_dir)?
} else {
manifest::Manifest::default()
};
let mut entries = Vec::new();
let dir_entries = match std::fs::read_dir(&pkg_dir) {
Ok(e) => e,
Err(_) => return Ok(empty()),
};
for entry in dir_entries.flatten() {
if !entry.path().is_dir() {
continue;
}
let dir_name = match entry.file_name().to_str() {
Some(n) if !n.starts_with('.') && !n.starts_with('_') => n.to_string(),
_ => continue,
};
let init_lua = entry.path().join("init.lua");
if !init_lua.exists() {
continue;
}
let Some(entity) = PkgEntity::parse_from_init_lua(&init_lua) else {
continue;
};
let source = manifest
.packages
.get(&dir_name)
.map(|e| e.source.clone())
.unwrap_or_default();
entries.push(IndexEntry {
entity,
source,
card_count: card_counts.get(&dir_name).copied().unwrap_or(0),
best_card: None,
});
}
entries.sort_by(|a, b| a.entity.name.cmp(&b.entity.name));
Ok(HubIndex {
schema_version: "hub_index/v0".into(),
updated_at: super::manifest::now_iso8601(),
packages: entries,
})
}
impl AppService {
pub fn hub_reindex(
&self,
output_path: Option<&str>,
source_dir: Option<&str>,
) -> Result<String, String> {
let src = source_dir.map(std::path::Path::new);
if let Some(d) = src {
if !d.is_dir() {
return Err(format!("source_dir '{}' is not a directory", d.display()));
}
}
let app_dir = self.log_config.app_dir();
let index = build_index(&app_dir, src)?;
let written_path = if let Some(path) = output_path {
let json = serde_json::to_string_pretty(&index)
.map_err(|e| format!("Failed to serialize index: {e}"))?;
std::fs::write(path, &json)
.map_err(|e| format!("Failed to write index to {path}: {e}"))?;
Some(path.to_string())
} else {
None
};
let response = serde_json::json!({
"package_count": index.packages.len(),
"updated_at": index.updated_at,
"output_path": written_path,
"source_dir": source_dir,
});
Ok(response.to_string())
}
pub fn hub_info(&self, pkg: &str) -> Result<String, String> {
use algocline_engine::card;
if pkg.contains("..") || pkg.contains('/') || pkg.contains('\\') {
return Err(format!("Invalid package name: '{pkg}'"));
}
let app_dir = self.log_config.app_dir();
let installed = installed_packages(&app_dir)?;
let is_installed = installed.contains_key(pkg);
let (version, description, category, source) = {
let (remote, _) = fetch_remote_indices(&app_dir)?;
if let Some(entry) = remote.packages.iter().find(|e| e.entity.name == pkg) {
(
entry.entity.version.clone().unwrap_or_default(),
entry.entity.description.clone().unwrap_or_default(),
entry.entity.category.clone().unwrap_or_default(),
entry.source.clone(),
)
} else if is_installed {
let init_lua = app_dir.packages_dir().join(pkg).join("init.lua");
let entity = PkgEntity::parse_from_init_lua(&init_lua);
let manifest_source = manifest::load_manifest(&app_dir)?
.packages
.get(pkg)
.map(|e| e.source.clone())
.unwrap_or_default();
match entity {
Some(e) => (
e.version.unwrap_or_default(),
e.description.unwrap_or_default(),
e.category.unwrap_or_default(),
manifest_source,
),
None => (
installed.get(pkg).cloned().flatten().unwrap_or_default(),
String::new(),
String::new(),
manifest_source,
),
}
} else {
return Err(format!(
"Package '{pkg}' not found in remote indices or locally installed packages"
));
}
};
let card_rows = self.card_store.list(Some(pkg)).unwrap_or_default();
let cards_json = card::summaries_to_json(&card_rows);
let aliases_json = match self.card_store.alias_list(Some(pkg)) {
Ok(rows) => card::aliases_to_json(&rows),
Err(_) => serde_json::json!([]),
};
let card_count = card_rows.len();
let best_pass_rate = card_rows
.iter()
.filter_map(|c| c.pass_rate)
.fold(f64::NEG_INFINITY, f64::max);
let best_pass_rate = if best_pass_rate.is_finite() {
Some(best_pass_rate)
} else {
None
};
let eval_count = count_evals_for_pkg(&app_dir, pkg);
let response = serde_json::json!({
"pkg": {
"name": pkg,
"version": version,
"description": description,
"category": category,
"source": source,
"installed": is_installed,
},
"cards": cards_json,
"aliases": aliases_json,
"stats": {
"card_count": card_count,
"eval_count": eval_count,
"best_pass_rate": best_pass_rate,
},
});
Ok(response.to_string())
}
pub(crate) fn hub_search(
&self,
query: Option<&str>,
category: Option<&str>,
installed_only: Option<bool>,
opts: ListOpts,
) -> Result<String, String> {
let app_dir = self.log_config.app_dir();
let (remote, warnings) = fetch_remote_indices(&app_dir)?;
let mut results = merge(&app_dir, &remote)?;
let query_lower = query.filter(|q| !q.is_empty()).map(|q| q.to_lowercase());
if let Some(ref ql) = query_lower {
results.retain(|r| matches_query(r, ql));
}
if let Some(ref ql) = query_lower {
for r in &mut results {
let empty = String::new();
let pkg = &r.entity;
let other_hit = pkg.name.to_lowercase().contains(ql)
|| pkg
.description
.as_ref()
.unwrap_or(&empty)
.to_lowercase()
.contains(ql)
|| pkg
.category
.as_ref()
.unwrap_or(&empty)
.to_lowercase()
.contains(ql);
let doc_hit = pkg
.docstring
.as_ref()
.unwrap_or(&empty)
.to_lowercase()
.contains(ql);
r.docstring_matched = if !other_hit && doc_hit {
Some(true)
} else {
None
};
}
}
let mut filter_map: std::collections::HashMap<String, serde_json::Value> =
opts.filter.unwrap_or_default();
if let Some(cat) = category {
filter_map
.entry("category".to_string())
.or_insert_with(|| serde_json::Value::String(cat.to_string()));
}
if let Some(only) = installed_only {
if only {
filter_map
.entry("installed".to_string())
.or_insert(serde_json::Value::Bool(true));
}
}
let sort_str = opts.sort.as_deref().unwrap_or("-installed,name");
let sort_keys = parse_sort(sort_str)?;
let fields = resolve_fields(
opts.verbose.as_deref(),
opts.fields.as_deref(),
HUB_SEARCH_SUMMARY,
HUB_SEARCH_FULL,
)?;
let include_docstring = fields.iter().any(|f| f == "docstring");
let mut items: Vec<serde_json::Value> = results
.iter()
.map(|r| r.to_value_with_optional_docstring(include_docstring))
.collect();
if !filter_map.is_empty() {
items.retain(|v| matches_filter(v, &filter_map));
}
apply_sort_by_value(&mut items, &sort_keys);
let total = items.len();
let limit = opts.limit.unwrap_or(50);
if limit > 0 {
items.truncate(limit);
}
let projected: Vec<serde_json::Value> = items
.into_iter()
.map(|v| project_fields(v, &fields))
.collect();
let sources = discover_index_urls(&app_dir)?;
let mut json = serde_json::json!({
"results": projected,
"total": total,
"sources": sources,
});
if !warnings.is_empty() {
json["warnings"] = serde_json::json!(warnings);
}
Ok(json.to_string())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn repo_to_index_url_github() {
assert_eq!(
repo_to_index_url("https://github.com/ynishi/algocline-bundled-packages"),
Some(
"https://raw.githubusercontent.com/ynishi/algocline-bundled-packages/main/hub_index.json"
.to_string()
)
);
}
#[test]
fn repo_to_index_url_github_trailing_slash() {
assert_eq!(
repo_to_index_url("https://github.com/user/repo/"),
Some("https://raw.githubusercontent.com/user/repo/main/hub_index.json".to_string())
);
}
#[test]
fn repo_to_index_url_github_dot_git() {
assert_eq!(
repo_to_index_url("https://github.com/user/repo.git"),
Some("https://raw.githubusercontent.com/user/repo/main/hub_index.json".to_string())
);
}
#[test]
fn repo_to_index_url_direct_json() {
assert_eq!(
repo_to_index_url("https://example.com/my_index.json"),
Some("https://example.com/my_index.json".to_string())
);
}
#[test]
fn repo_to_index_url_unknown_host_no_json() {
assert_eq!(repo_to_index_url("https://example.com/some-repo"), None);
}
#[test]
fn repo_to_index_url_local_path() {
assert_eq!(repo_to_index_url("/home/user/my-pkg"), None);
}
#[test]
fn cache_key_stable() {
let k1 = cache_key("https://example.com/index.json");
let k2 = cache_key("https://example.com/index.json");
assert_eq!(k1, k2);
assert_eq!(k1.len(), 16); }
#[test]
fn cache_key_different_urls() {
let k1 = cache_key("https://a.com/index.json");
let k2 = cache_key("https://b.com/index.json");
assert_ne!(k1, k2);
}
#[test]
fn merge_dedup_uses_hashset() {
let tmp = tempfile::tempdir().unwrap();
let app_dir = AppDir::new(tmp.path().to_path_buf());
let remote = HubIndex {
schema_version: "hub_index/v0".into(),
updated_at: String::new(),
packages: vec![IndexEntry {
entity: PkgEntity {
name: "remote_only".into(),
version: Some("1.0".into()),
description: Some("from remote".into()),
category: Some("test".into()),
docstring: None,
},
source: PackageSource::Unknown,
card_count: 0,
best_card: None,
}],
};
let results = merge(&app_dir, &remote).expect("merge over empty app_dir should succeed");
assert!(results.iter().any(|r| r.entity.name == "remote_only"));
}
#[test]
fn matches_query_searches_docstring() {
let result = SearchResult {
entity: PkgEntity {
name: "cascade".into(),
version: Some("0.1.0".into()),
description: Some("Multi-level routing".into()),
category: Some("meta".into()),
docstring: Some("Based on FrugalGPT. Uses Thompson Sampling.".into()),
},
source: PackageSource::Unknown,
installed: true,
card_count: 0,
best_card: None,
docstring_matched: None,
};
assert!(matches_query(&result, "thompson"), "docstring match");
assert!(matches_query(&result, "FrugalGPT"), "docstring match case");
assert!(matches_query(&result, "routing"), "description match");
assert!(!matches_query(&result, "bayesian"), "no match");
}
fn sample_search_result() -> SearchResult {
SearchResult {
entity: PkgEntity {
name: "cascade".into(),
version: Some("0.1.0".into()),
description: Some("Multi-level routing".into()),
category: Some("reasoning".into()),
docstring: Some("Based on FrugalGPT. Uses Thompson Sampling.".into()),
},
source: PackageSource::Git {
url: "https://example.com/cascade".into(),
rev: None,
},
installed: true,
card_count: 3,
best_card: None,
docstring_matched: None,
}
}
#[test]
fn to_value_default_omits_docstring() {
let r = sample_search_result();
let v = r.to_value_with_optional_docstring(false);
let obj = v.as_object().expect("object");
assert!(
!obj.contains_key("docstring"),
"default summary must not leak docstring"
);
assert_eq!(obj.get("name").and_then(|x| x.as_str()), Some("cascade"));
assert!(
!obj.contains_key("docstring_matched"),
"docstring_matched=None must be omitted"
);
}
#[test]
fn to_value_include_reattaches_docstring() {
let r = sample_search_result();
let v = r.to_value_with_optional_docstring(true);
let obj = v.as_object().expect("object");
assert_eq!(
obj.get("docstring").and_then(|x| x.as_str()),
Some("Based on FrugalGPT. Uses Thompson Sampling.")
);
}
#[test]
fn to_value_serializes_docstring_matched_when_set() {
let mut r = sample_search_result();
r.docstring_matched = Some(true);
let v = r.to_value_with_optional_docstring(false);
let obj = v.as_object().expect("object");
assert_eq!(
obj.get("docstring_matched").and_then(|x| x.as_bool()),
Some(true)
);
}
#[test]
fn hub_search_default_summary_excludes_docstring() {
let r = sample_search_result();
let fields = resolve_fields(None, None, HUB_SEARCH_SUMMARY, HUB_SEARCH_FULL).unwrap();
let include_docstring = fields.iter().any(|f| f == "docstring");
let v = project_fields(
r.to_value_with_optional_docstring(include_docstring),
&fields,
);
let obj = v.as_object().expect("object");
assert!(
!obj.contains_key("docstring"),
"summary preset must omit docstring"
);
for key in ["name", "version", "description", "category", "installed"] {
assert!(obj.contains_key(key), "summary preset key {key} missing");
}
}
#[test]
fn hub_search_verbose_full_includes_docstring() {
let r = sample_search_result();
let fields =
resolve_fields(Some("full"), None, HUB_SEARCH_SUMMARY, HUB_SEARCH_FULL).unwrap();
let include_docstring = fields.iter().any(|f| f == "docstring");
let v = project_fields(
r.to_value_with_optional_docstring(include_docstring),
&fields,
);
let obj = v.as_object().expect("object");
assert_eq!(
obj.get("docstring").and_then(|x| x.as_str()),
Some("Based on FrugalGPT. Uses Thompson Sampling.")
);
for key in ["source", "card_count"] {
assert!(obj.contains_key(key), "full preset key {key} missing");
}
}
#[test]
fn hub_search_fields_beats_verbose() {
let r = sample_search_result();
let explicit = vec!["name".to_string(), "docstring".to_string()];
let fields = resolve_fields(
Some("summary"),
Some(&explicit),
HUB_SEARCH_SUMMARY,
HUB_SEARCH_FULL,
)
.unwrap();
let include_docstring = fields.iter().any(|f| f == "docstring");
let v = project_fields(
r.to_value_with_optional_docstring(include_docstring),
&fields,
);
let obj = v.as_object().expect("object");
assert_eq!(obj.len(), 2, "only the two requested fields");
assert!(obj.contains_key("name"));
assert!(obj.contains_key("docstring"));
}
#[test]
fn hub_search_fields_unknown_key_silently_skipped() {
let r = sample_search_result();
let explicit = vec!["name".to_string(), "bogus".to_string()];
let fields =
resolve_fields(None, Some(&explicit), HUB_SEARCH_SUMMARY, HUB_SEARCH_FULL).unwrap();
let v = project_fields(r.to_value_with_optional_docstring(false), &fields);
let obj = v.as_object().expect("object");
assert_eq!(obj.len(), 1, "bogus must not appear");
assert!(obj.contains_key("name"));
}
#[test]
fn hub_search_invalid_verbose_errors() {
let err =
resolve_fields(Some("fat"), None, HUB_SEARCH_SUMMARY, HUB_SEARCH_FULL).unwrap_err();
assert!(
err.contains("fat"),
"error must mention the offending value"
);
}
fn classify(r: &SearchResult, query: &str) -> Option<bool> {
let ql = query.to_lowercase();
if query.is_empty() {
return None;
}
let empty = String::new();
let pkg = &r.entity;
let other_hit = pkg.name.to_lowercase().contains(&ql)
|| pkg
.description
.as_ref()
.unwrap_or(&empty)
.to_lowercase()
.contains(&ql)
|| pkg
.category
.as_ref()
.unwrap_or(&empty)
.to_lowercase()
.contains(&ql);
let doc_hit = pkg
.docstring
.as_ref()
.unwrap_or(&empty)
.to_lowercase()
.contains(&ql);
if !other_hit && doc_hit {
Some(true)
} else {
None
}
}
#[test]
fn docstring_matched_true_when_only_docstring_hits() {
let r = sample_search_result();
assert_eq!(classify(&r, "thompson"), Some(true));
}
#[test]
fn docstring_matched_none_when_name_also_hits() {
let r = sample_search_result();
assert_eq!(classify(&r, "cascade"), None);
}
#[test]
fn docstring_matched_none_when_description_hits() {
let r = sample_search_result();
assert_eq!(classify(&r, "routing"), None);
}
#[test]
fn docstring_matched_none_when_query_empty() {
let r = sample_search_result();
assert_eq!(classify(&r, ""), None);
}
fn build_filter_map(
category: Option<&str>,
installed_only: Option<bool>,
explicit: Option<HashMap<String, serde_json::Value>>,
) -> HashMap<String, serde_json::Value> {
let mut filter_map = explicit.unwrap_or_default();
if let Some(cat) = category {
filter_map
.entry("category".to_string())
.or_insert_with(|| serde_json::Value::String(cat.to_string()));
}
if let Some(only) = installed_only {
if only {
filter_map
.entry("installed".to_string())
.or_insert(serde_json::Value::Bool(true));
}
}
filter_map
}
#[test]
fn filter_by_category_via_legacy_param() {
let m = build_filter_map(Some("reasoning"), None, None);
assert_eq!(
m.get("category"),
Some(&serde_json::Value::String("reasoning".to_string()))
);
}
#[test]
fn filter_by_installed_only_via_legacy_param() {
let m = build_filter_map(None, Some(true), None);
assert_eq!(m.get("installed"), Some(&serde_json::Value::Bool(true)));
}
#[test]
fn filter_installed_only_false_is_noop() {
let m = build_filter_map(None, Some(false), None);
assert!(
!m.contains_key("installed"),
"installed_only=false should not fold in"
);
}
#[test]
fn filter_beats_legacy_param_on_conflict() {
let mut explicit = HashMap::new();
explicit.insert(
"category".to_string(),
serde_json::Value::String("meta".to_string()),
);
let m = build_filter_map(Some("reasoning"), None, Some(explicit));
assert_eq!(
m.get("category"),
Some(&serde_json::Value::String("meta".to_string()))
);
}
#[test]
fn filter_merges_legacy_when_no_conflict() {
let mut explicit = HashMap::new();
explicit.insert("installed".to_string(), serde_json::Value::Bool(true));
let m = build_filter_map(Some("reasoning"), None, Some(explicit));
assert_eq!(
m.get("category"),
Some(&serde_json::Value::String("reasoning".to_string()))
);
assert_eq!(m.get("installed"), Some(&serde_json::Value::Bool(true)));
}
#[test]
fn default_sort_is_minus_installed_name() {
let keys = parse_sort("-installed,name").unwrap();
assert_eq!(keys.len(), 2);
assert_eq!(keys[0].key, "installed");
assert!(keys[0].desc, "installed must sort desc (true first)");
assert_eq!(keys[1].key, "name");
assert!(!keys[1].desc);
let mut items = vec![
serde_json::json!({"installed": false, "name": "zeta"}),
serde_json::json!({"installed": true, "name": "mu"}),
serde_json::json!({"installed": false, "name": "alpha"}),
serde_json::json!({"installed": true, "name": "beta"}),
];
apply_sort_by_value(&mut items, &keys);
let names: Vec<&str> = items
.iter()
.map(|v| v.get("name").and_then(|x| x.as_str()).unwrap_or(""))
.collect();
assert_eq!(names, vec!["beta", "mu", "alpha", "zeta"]);
}
}