use std::collections::{HashMap, HashSet};
use std::path::PathBuf;
use serde::{Deserialize, Serialize};
use super::manifest;
use super::resolve::AUTO_INSTALL_SOURCES;
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 {
pub name: String,
#[serde(default)]
pub version: String,
#[serde(default)]
pub description: String,
#[serde(default)]
pub category: String,
#[serde(default)]
pub source: String,
#[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 {
name: String,
version: String,
description: String,
category: String,
source: String,
installed: bool,
card_count: usize,
best_card: Option<BestCard>,
}
#[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() -> Result<PathBuf, String> {
let home = dirs::home_dir().ok_or("Cannot determine home directory")?;
Ok(home.join(".algocline").join("hub_registries.json"))
}
fn load_registries() -> HubRegistries {
let path = match registries_path() {
Ok(p) => p,
Err(_) => return HubRegistries::default(),
};
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(source: &str, origin: &str) {
let normalized = source.trim_end_matches('/').to_string();
if normalized.is_empty() {
return;
}
if normalized.starts_with('/') || normalized.starts_with('.') {
return;
}
let path = match registries_path() {
Ok(p) => p,
Err(_) => return,
};
if let Some(parent) = path.parent() {
let _ = std::fs::create_dir_all(parent);
}
let mut reg = load_registries();
if reg
.registries
.iter()
.any(|e| e.source.trim_end_matches('/') == normalized)
{
return;
}
reg.registries.push(RegistryEntry {
source: normalized,
origin: origin.to_string(),
added_at: manifest::now_iso8601(),
});
match serde_json::to_string_pretty(®) {
Ok(json) => {
let tmp_path = path.with_extension("json.tmp");
if let Err(e) = std::fs::write(&tmp_path, &json) {
tracing::warn!("failed to write hub registries tmp: {e}");
return;
}
if let Err(e) = std::fs::rename(&tmp_path, &path) {
tracing::warn!("failed to rename hub registries: {e}");
let _ = std::fs::remove_file(&tmp_path);
}
}
Err(e) => tracing::warn!("failed to serialize hub registries: {e}"),
}
}
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() -> Vec<String> {
let mut repo_urls: HashSet<String> = HashSet::new();
let reg = load_registries();
for entry in ®.registries {
let normalized = entry.source.trim_end_matches('/').to_string();
if !normalized.is_empty() {
repo_urls.insert(normalized);
}
}
if let Ok(m) = manifest::load_manifest() {
for entry in m.packages.values() {
let normalized = entry.source.trim_end_matches('/').to_string();
if !normalized.is_empty() && !normalized.starts_with('/') {
repo_urls.insert(normalized);
}
}
}
for url in AUTO_INSTALL_SOURCES {
repo_urls.insert(url.to_string());
}
let mut index_urls: Vec<String> = repo_urls
.iter()
.filter_map(|url| repo_to_index_url(url))
.collect();
index_urls.sort();
index_urls.dedup();
index_urls
}
fn cache_dir() -> Result<PathBuf, String> {
let home = dirs::home_dir().ok_or("Cannot determine home directory")?;
Ok(home.join(".algocline").join("hub_cache"))
}
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(url: &str) -> Option<HubIndex> {
let dir = cache_dir().ok()?;
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(url: &str, index: &HubIndex) {
let dir = match cache_dir() {
Ok(d) => d,
Err(e) => {
tracing::warn!("hub cache dir unavailable: {e}");
return;
}
};
if let Err(e) = std::fs::create_dir_all(&dir) {
tracing::warn!("failed to create hub cache dir: {e}");
return;
}
let path = dir.join(format!("{}.json", cache_key(url)));
match serde_json::to_string_pretty(index) {
Ok(json) => {
if let Err(e) = std::fs::write(&path, json) {
tracing::warn!("failed to write hub cache {}: {e}", path.display());
}
}
Err(e) => tracing::warn!("failed to serialize hub cache: {e}"),
}
}
fn fetch_one(url: &str) -> Result<HubIndex, String> {
if let Some(cached) = load_cached(url) {
return Ok(cached);
}
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}"))?;
save_cached(url, &index);
Ok(index)
}
fn fetch_remote_indices() -> (HubIndex, Vec<String>) {
let urls = discover_index_urls();
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(url) {
Ok(index) => {
for entry in index.packages {
if seen_names.insert(entry.name.clone()) {
all_packages.push(entry);
}
}
}
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,
};
(merged, warnings)
}
fn installed_packages() -> HashMap<String, Option<String>> {
let mut map = HashMap::new();
if let Ok(m) = manifest::load_manifest() {
for (name, entry) in &m.packages {
map.insert(name.clone(), entry.version.clone());
}
}
if let Some(home) = dirs::home_dir() {
let pkg_dir = home.join(".algocline").join("packages");
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);
}
}
}
}
}
map
}
fn local_card_counts() -> HashMap<String, usize> {
let mut map = HashMap::new();
let home = match dirs::home_dir() {
Some(h) => h,
None => return map,
};
let cards_dir = home.join(".algocline").join("cards");
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 merge(remote: &HubIndex) -> Vec<SearchResult> {
let installed = installed_packages();
let card_counts = local_card_counts();
let mut seen: HashSet<String> = HashSet::new();
let mut results: Vec<SearchResult> = Vec::new();
for entry in &remote.packages {
let is_installed = installed.contains_key(&entry.name);
let local_cards = card_counts.get(&entry.name).copied().unwrap_or(0);
seen.insert(entry.name.clone());
results.push(SearchResult {
name: entry.name.clone(),
version: entry.version.clone(),
description: entry.description.clone(),
category: entry.category.clone(),
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(),
});
}
for (name, version) in &installed {
if seen.contains(name) {
continue;
}
results.push(SearchResult {
name: name.clone(),
version: version.clone().unwrap_or_default(),
description: String::new(),
category: String::new(),
source: String::new(),
installed: true,
card_count: card_counts.get(name).copied().unwrap_or(0),
best_card: None,
});
}
results
}
fn matches_query(result: &SearchResult, query: &str) -> bool {
let q = query.to_lowercase();
result.name.to_lowercase().contains(&q)
|| result.description.to_lowercase().contains(&q)
|| result.category.to_lowercase().contains(&q)
}
fn parse_meta_from_init_lua(path: &std::path::Path) -> Option<(String, String, String, String)> {
let content = std::fs::read_to_string(path).ok()?;
let mut limit = 2048.min(content.len());
while limit > 0 && !content.is_char_boundary(limit) {
limit -= 1;
}
let head = &content[..limit];
let meta_start = head.find("M.meta")?;
let brace_start = head[meta_start..].find('{')? + meta_start;
let mut depth = 0;
let mut brace_end = None;
for (i, ch) in head[brace_start..].char_indices() {
match ch {
'{' => depth += 1,
'}' => {
depth -= 1;
if depth == 0 {
brace_end = Some(brace_start + i);
break;
}
}
_ => {}
}
}
let brace_end = brace_end?;
let block = &head[brace_start + 1..brace_end];
let extract = |field: &str| -> String {
let mut search_from = 0;
while let Some(rel) = block[search_from..].find(field) {
let pos = search_from + rel;
let word_boundary = if pos == 0 {
true
} else {
let prev = block.as_bytes()[pos - 1];
!(prev.is_ascii_alphanumeric() || prev == b'_')
};
if word_boundary {
let after = &block[pos + field.len()..];
if let Some(q_start_rel) = after.find('"') {
let q_start = q_start_rel + 1;
if let Some(q_end_rel) = after[q_start..].find('"') {
return after[q_start..q_start + q_end_rel].to_string();
}
}
}
search_from = pos + field.len();
}
String::new()
};
let name = extract("name");
if name.is_empty() {
return None;
}
Some((
name,
extract("version"),
extract("description"),
extract("category"),
))
}
fn build_index(source_dir: Option<&std::path::Path>) -> HubIndex {
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 => {
let home = match dirs::home_dir() {
Some(h) => h,
None => return empty(),
};
home.join(".algocline").join("packages")
}
};
let use_local_state = source_dir.is_none();
let card_counts = if use_local_state {
local_card_counts()
} else {
HashMap::new()
};
let manifest = if use_local_state {
manifest::load_manifest().unwrap_or_default()
} else {
manifest::Manifest::default()
};
let mut entries = Vec::new();
let dir_entries = match std::fs::read_dir(&pkg_dir) {
Ok(e) => e,
Err(_) => return 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 (name, version, description, category) = parse_meta_from_init_lua(&init_lua)
.unwrap_or_else(|| {
(
dir_name.clone(),
String::new(),
String::new(),
String::new(),
)
});
let source = manifest
.packages
.get(&dir_name)
.map(|e| e.source.clone())
.unwrap_or_default();
entries.push(IndexEntry {
name,
version,
description,
category,
source,
card_count: card_counts.get(&dir_name).copied().unwrap_or(0),
best_card: None,
});
}
entries.sort_by(|a, b| a.name.cmp(&b.name));
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 index = build_index(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_search(
&self,
query: Option<&str>,
category: Option<&str>,
installed_only: Option<bool>,
limit: Option<usize>,
) -> Result<String, String> {
let (remote, warnings) = fetch_remote_indices();
let mut results = merge(&remote);
if let Some(q) = query {
if !q.is_empty() {
results.retain(|r| matches_query(r, q));
}
}
if let Some(cat) = category {
let cat_lower = cat.to_lowercase();
results.retain(|r| r.category.to_lowercase() == cat_lower);
}
if let Some(true) = installed_only {
results.retain(|r| r.installed);
}
results.sort_by(|a, b| {
b.installed
.cmp(&a.installed)
.then_with(|| a.name.cmp(&b.name))
});
let total = results.len();
let limit = limit.unwrap_or(50);
results.truncate(limit);
let sources = discover_index_urls();
let mut json = serde_json::json!({
"results": results,
"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 parse_meta_flat() {
let tmp = tempfile::tempdir().unwrap();
let path = tmp.path().join("init.lua");
std::fs::write(
&path,
r#"
local M = {}
M.meta = {
name = "my_pkg",
version = "1.0.0",
description = "A test package",
category = "reasoning",
}
return M
"#,
)
.unwrap();
let result = parse_meta_from_init_lua(&path).unwrap();
assert_eq!(result.0, "my_pkg");
assert_eq!(result.1, "1.0.0");
assert_eq!(result.2, "A test package");
assert_eq!(result.3, "reasoning");
}
#[test]
fn parse_meta_nested_table() {
let tmp = tempfile::tempdir().unwrap();
let path = tmp.path().join("init.lua");
std::fs::write(
&path,
r#"
local M = {}
M.meta = {
name = "nested_pkg",
tags = { "a", "b" },
description = "After nested",
}
return M
"#,
)
.unwrap();
let result = parse_meta_from_init_lua(&path).unwrap();
assert_eq!(result.0, "nested_pkg");
assert_eq!(result.2, "After nested");
}
#[test]
fn parse_meta_word_boundary() {
let tmp = tempfile::tempdir().unwrap();
let path = tmp.path().join("init.lua");
std::fs::write(
&path,
r#"
local M = {}
M.meta = {
name = "wb_pkg",
short_description = "should not match",
description = "correct one",
}
return M
"#,
)
.unwrap();
let result = parse_meta_from_init_lua(&path).unwrap();
assert_eq!(result.0, "wb_pkg");
assert_eq!(result.2, "correct one");
}
#[test]
fn merge_dedup_uses_hashset() {
let remote = HubIndex {
schema_version: "hub_index/v0".into(),
updated_at: String::new(),
packages: vec![IndexEntry {
name: "remote_only".into(),
version: "1.0".into(),
description: "from remote".into(),
category: "test".into(),
source: String::new(),
card_count: 0,
best_card: None,
}],
};
let results = merge(&remote);
assert!(results.iter().any(|r| r.name == "remote_only"));
}
}