1use std::collections::{HashMap, HashSet};
77use std::path::PathBuf;
78
79use serde::{Deserialize, Serialize};
80
81use super::manifest;
82use super::resolve::AUTO_INSTALL_SOURCES;
83use super::AppService;
84
85const CACHE_TTL_SECS: u64 = 3600;
89
90const HTTP_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(30);
92
93#[derive(Debug, Clone, Serialize, Deserialize)]
97pub(crate) struct HubIndex {
98 pub schema_version: String,
99 #[serde(default)]
100 pub updated_at: String,
101 #[serde(default)]
102 pub packages: Vec<IndexEntry>,
103}
104
105#[derive(Debug, Clone, Serialize, Deserialize)]
107pub(crate) struct IndexEntry {
108 pub name: String,
109 #[serde(default)]
110 pub version: String,
111 #[serde(default)]
112 pub description: String,
113 #[serde(default)]
114 pub category: String,
115 #[serde(default)]
116 pub source: String,
117 #[serde(default)]
118 pub card_count: usize,
119 #[serde(default)]
120 pub best_card: Option<BestCard>,
121 #[serde(default)]
123 pub docstring: String,
124}
125
126#[derive(Debug, Clone, Serialize, Deserialize)]
128pub(crate) struct BestCard {
129 pub card_id: String,
130 #[serde(default)]
131 pub model: String,
132 #[serde(default)]
133 pub pass_rate: f64,
134 #[serde(default)]
135 pub scenario: String,
136}
137
138#[derive(Debug, Clone, Serialize)]
140struct SearchResult {
141 name: String,
142 version: String,
143 description: String,
144 category: String,
145 source: String,
146 installed: bool,
147 card_count: usize,
148 best_card: Option<BestCard>,
149 #[serde(default, skip_serializing_if = "String::is_empty")]
150 docstring: String,
151}
152
153#[derive(Debug, Clone, Serialize, Deserialize)]
162pub(crate) struct RegistryEntry {
163 pub source: String,
165 pub origin: String,
167 pub added_at: String,
169}
170
171#[derive(Debug, Clone, Serialize, Deserialize, Default)]
173pub(crate) struct HubRegistries {
174 pub registries: Vec<RegistryEntry>,
175}
176
177fn registries_path() -> Result<PathBuf, String> {
178 let home = dirs::home_dir().ok_or("Cannot determine home directory")?;
179 Ok(home.join(".algocline").join("hub_registries.json"))
180}
181
182fn load_registries() -> HubRegistries {
184 let path = match registries_path() {
185 Ok(p) => p,
186 Err(_) => return HubRegistries::default(),
187 };
188 if !path.exists() {
189 return HubRegistries::default();
190 }
191 std::fs::read_to_string(&path)
192 .ok()
193 .and_then(|c| serde_json::from_str(&c).ok())
194 .unwrap_or_default()
195}
196
197pub(crate) fn register_source(source: &str, origin: &str) {
204 let normalized = source.trim_end_matches('/').to_string();
205 if normalized.is_empty() {
206 return;
207 }
208 if normalized.starts_with('/') || normalized.starts_with('.') {
210 return;
211 }
212
213 let path = match registries_path() {
214 Ok(p) => p,
215 Err(_) => return,
216 };
217 if let Some(parent) = path.parent() {
218 let _ = std::fs::create_dir_all(parent);
219 }
220
221 let mut reg = load_registries();
223
224 if reg
226 .registries
227 .iter()
228 .any(|e| e.source.trim_end_matches('/') == normalized)
229 {
230 return;
231 }
232
233 reg.registries.push(RegistryEntry {
234 source: normalized,
235 origin: origin.to_string(),
236 added_at: manifest::now_iso8601(),
237 });
238
239 match serde_json::to_string_pretty(®) {
241 Ok(json) => {
242 let tmp_path = path.with_extension("json.tmp");
243 if let Err(e) = std::fs::write(&tmp_path, &json) {
244 tracing::warn!("failed to write hub registries tmp: {e}");
245 return;
246 }
247 if let Err(e) = std::fs::rename(&tmp_path, &path) {
248 tracing::warn!("failed to rename hub registries: {e}");
249 let _ = std::fs::remove_file(&tmp_path);
251 }
252 }
253 Err(e) => tracing::warn!("failed to serialize hub registries: {e}"),
254 }
255}
256
257fn collection_url_from_config() -> Option<String> {
269 let home = dirs::home_dir()?;
270 let path = home.join(".algocline").join("config.toml");
271 let content = std::fs::read_to_string(&path).ok()?;
272 let doc: toml_edit::DocumentMut = content.parse().ok()?;
273 let url = doc
274 .get("hub")?
275 .get("collection_url")?
276 .as_str()?
277 .trim()
278 .to_string();
279 if url.is_empty() {
280 None
281 } else {
282 Some(url)
283 }
284}
285
286fn repo_to_index_url(repo_url: &str) -> Option<String> {
301 let trimmed = repo_url.trim_end_matches('/').trim_end_matches(".git");
302 if let Some(path) = trimmed.strip_prefix("https://github.com/") {
303 let parts: Vec<&str> = path.splitn(3, '/').collect();
305 if parts.len() >= 2 {
306 return Some(format!(
307 "https://raw.githubusercontent.com/{}/{}/main/hub_index.json",
308 parts[0], parts[1]
309 ));
310 }
311 }
312 if trimmed.ends_with(".json") {
314 Some(trimmed.to_string())
315 } else {
316 None
317 }
318}
319
320fn discover_index_urls() -> Vec<String> {
322 let mut index_urls: Vec<String> = Vec::new();
323
324 if let Some(url) = collection_url_from_config() {
326 index_urls.push(url);
327 }
328
329 let mut repo_urls: HashSet<String> = HashSet::new();
330
331 let reg = load_registries();
333 for entry in ®.registries {
334 let normalized = entry.source.trim_end_matches('/').to_string();
335 if !normalized.is_empty() {
336 repo_urls.insert(normalized);
337 }
338 }
339
340 if let Ok(m) = manifest::load_manifest() {
342 for entry in m.packages.values() {
343 let normalized = entry.source.trim_end_matches('/').to_string();
344 if !normalized.is_empty() && !normalized.starts_with('/') {
345 repo_urls.insert(normalized);
346 }
347 }
348 }
349
350 for url in AUTO_INSTALL_SOURCES {
352 repo_urls.insert(url.to_string());
353 }
354
355 let existing: HashSet<String> = index_urls.iter().cloned().collect();
357 let mut derived: Vec<String> = repo_urls
358 .iter()
359 .filter_map(|url| repo_to_index_url(url))
360 .filter(|url| !existing.contains(url))
361 .collect();
362 derived.sort();
363 derived.dedup();
364 index_urls.extend(derived);
365
366 index_urls
367}
368
369fn cache_dir() -> Result<PathBuf, String> {
377 let home = dirs::home_dir().ok_or("Cannot determine home directory")?;
378 Ok(home.join(".algocline").join("hub_cache"))
379}
380
381fn cache_key(url: &str) -> String {
382 let mut h: u64 = 0xcbf2_9ce4_8422_2325; for b in url.as_bytes() {
386 h ^= *b as u64;
387 h = h.wrapping_mul(0x0100_0000_01b3); }
389 format!("{h:016x}")
390}
391
392fn load_cached(url: &str) -> Option<HubIndex> {
394 let dir = cache_dir().ok()?;
395 let path = dir.join(format!("{}.json", cache_key(url)));
396 if !path.exists() {
397 return None;
398 }
399 let metadata = std::fs::metadata(&path).ok()?;
400 let age = metadata.modified().ok()?.elapsed().ok()?;
401 if age.as_secs() > CACHE_TTL_SECS {
402 return None;
403 }
404 let content = std::fs::read_to_string(&path).ok()?;
405 serde_json::from_str(&content).ok()
406}
407
408fn save_cached(url: &str, index: &HubIndex) {
410 let dir = match cache_dir() {
411 Ok(d) => d,
412 Err(e) => {
413 tracing::warn!("hub cache dir unavailable: {e}");
414 return;
415 }
416 };
417 if let Err(e) = std::fs::create_dir_all(&dir) {
418 tracing::warn!("failed to create hub cache dir: {e}");
419 return;
420 }
421 let path = dir.join(format!("{}.json", cache_key(url)));
422 match serde_json::to_string_pretty(index) {
423 Ok(json) => {
424 if let Err(e) = std::fs::write(&path, json) {
425 tracing::warn!("failed to write hub cache {}: {e}", path.display());
426 }
427 }
428 Err(e) => tracing::warn!("failed to serialize hub cache: {e}"),
429 }
430}
431
432fn fetch_one(url: &str) -> Result<HubIndex, String> {
436 if let Some(cached) = load_cached(url) {
437 return Ok(cached);
438 }
439
440 let agent = ureq::Agent::new_with_config(
441 ureq::config::Config::builder()
442 .timeout_global(Some(HTTP_TIMEOUT))
443 .build(),
444 );
445 let body: String = agent
446 .get(url)
447 .call()
448 .map_err(|e| format!("Failed to fetch {url}: {e}"))?
449 .body_mut()
450 .read_to_string()
451 .map_err(|e| format!("Failed to read response from {url}: {e}"))?;
452
453 let index: HubIndex = serde_json::from_str(&body)
454 .map_err(|e| format!("Failed to parse index from {url}: {e}"))?;
455
456 save_cached(url, &index);
457 Ok(index)
458}
459
460fn fetch_remote_indices() -> (HubIndex, Vec<String>) {
463 let urls = discover_index_urls();
464 let mut all_packages: Vec<IndexEntry> = Vec::new();
465 let mut seen_names: HashSet<String> = HashSet::new();
466 let mut warnings: Vec<String> = Vec::new();
467
468 for url in &urls {
469 match fetch_one(url) {
470 Ok(index) => {
471 for entry in index.packages {
472 if seen_names.insert(entry.name.clone()) {
473 all_packages.push(entry);
474 }
475 }
477 }
478 Err(e) => {
479 warnings.push(e);
480 }
481 }
482 }
483
484 if all_packages.is_empty() && !warnings.is_empty() {
485 warnings.insert(
486 0,
487 "all remote indices unavailable, showing local packages only".to_string(),
488 );
489 }
490
491 let merged = HubIndex {
492 schema_version: "hub_index/v0".into(),
493 updated_at: String::new(),
494 packages: all_packages,
495 };
496 (merged, warnings)
497}
498
499fn installed_packages() -> HashMap<String, Option<String>> {
504 let mut map = HashMap::new();
505
506 if let Ok(m) = manifest::load_manifest() {
508 for (name, entry) in &m.packages {
509 map.insert(name.clone(), entry.version.clone());
510 }
511 }
512
513 if let Some(home) = dirs::home_dir() {
515 let pkg_dir = home.join(".algocline").join("packages");
516 if let Ok(entries) = std::fs::read_dir(&pkg_dir) {
517 for entry in entries.flatten() {
518 if entry.path().is_dir() {
519 if let Some(name) = entry.file_name().to_str() {
520 map.entry(name.to_string()).or_insert(None);
521 }
522 }
523 }
524 }
525 }
526
527 map
528}
529
530fn local_card_counts() -> HashMap<String, usize> {
532 let mut map = HashMap::new();
533 let home = match dirs::home_dir() {
534 Some(h) => h,
535 None => return map,
536 };
537 let cards_dir = home.join(".algocline").join("cards");
538 let entries = match std::fs::read_dir(&cards_dir) {
539 Ok(e) => e,
540 Err(_) => return map,
541 };
542 for entry in entries.flatten() {
543 if !entry.path().is_dir() {
544 continue;
545 }
546 let pkg = match entry.file_name().to_str() {
547 Some(n) => n.to_string(),
548 None => continue,
549 };
550 let count = std::fs::read_dir(entry.path())
551 .map(|es| {
552 es.flatten()
553 .filter(|e| e.path().extension().is_some_and(|ext| ext == "toml"))
554 .count()
555 })
556 .unwrap_or(0);
557 if count > 0 {
558 map.insert(pkg, count);
559 }
560 }
561 map
562}
563
564fn count_evals_for_pkg(pkg: &str) -> usize {
569 let home = match dirs::home_dir() {
570 Some(h) => h,
571 None => return 0,
572 };
573 let evals_dir = home.join(".algocline").join("evals");
574 let entries = match std::fs::read_dir(&evals_dir) {
575 Ok(e) => e,
576 Err(_) => return 0,
577 };
578
579 let mut meta_stems: HashSet<String> = HashSet::new();
582 let mut meta_matches: usize = 0;
583 let mut non_meta_paths: Vec<(PathBuf, String)> = Vec::new(); for entry in entries.flatten() {
586 let path = entry.path();
587 let name = match path.file_name().and_then(|n| n.to_str()) {
588 Some(n) => n.to_string(),
589 None => continue,
590 };
591
592 if name.ends_with(".meta.json") {
593 let stem = name.trim_end_matches(".meta.json").to_string();
594 meta_stems.insert(stem);
595 if let Ok(content) = std::fs::read_to_string(&path) {
596 if let Ok(val) = serde_json::from_str::<serde_json::Value>(&content) {
597 if val.get("strategy").and_then(|s| s.as_str()) == Some(pkg) {
598 meta_matches += 1;
599 }
600 }
601 }
602 continue;
603 }
604
605 if !name.ends_with(".json") || name.starts_with("compare_") {
607 continue;
608 }
609
610 let stem = path
611 .file_stem()
612 .and_then(|s| s.to_str())
613 .unwrap_or("")
614 .to_string();
615 non_meta_paths.push((path, stem));
616 }
617
618 let fallback_matches = non_meta_paths
620 .iter()
621 .filter(|(_, stem)| !meta_stems.contains(stem))
622 .filter(|(path, _)| {
623 std::fs::read_to_string(path)
624 .ok()
625 .and_then(|c| serde_json::from_str::<serde_json::Value>(&c).ok())
626 .and_then(|v| v.get("strategy")?.as_str().map(|s| s == pkg))
627 .unwrap_or(false)
628 })
629 .count();
630
631 meta_matches + fallback_matches
632}
633
634fn merge(remote: &HubIndex) -> Vec<SearchResult> {
642 let installed = installed_packages();
643 let card_counts = local_card_counts();
644 let pkg_dir = dirs::home_dir().map(|h| h.join(".algocline").join("packages"));
645
646 let mut seen: HashSet<String> = HashSet::new();
647 let mut results: Vec<SearchResult> = Vec::new();
648
649 for entry in &remote.packages {
650 let is_installed = installed.contains_key(&entry.name);
651 let local_cards = card_counts.get(&entry.name).copied().unwrap_or(0);
652
653 let docstring = if entry.docstring.is_empty() && is_installed {
655 pkg_dir
656 .as_ref()
657 .map(|d| extract_docstring(&d.join(&entry.name).join("init.lua")))
658 .unwrap_or_default()
659 } else {
660 entry.docstring.clone()
661 };
662
663 seen.insert(entry.name.clone());
664 results.push(SearchResult {
665 name: entry.name.clone(),
666 version: entry.version.clone(),
667 description: entry.description.clone(),
668 category: entry.category.clone(),
669 source: entry.source.clone(),
670 installed: is_installed,
671 card_count: if is_installed && local_cards > entry.card_count {
672 local_cards
673 } else {
674 entry.card_count
675 },
676 best_card: entry.best_card.clone(),
677 docstring,
678 });
679 }
680
681 for (name, version) in &installed {
683 if seen.contains(name) {
684 continue;
685 }
686 let docstring = pkg_dir
687 .as_ref()
688 .map(|d| extract_docstring(&d.join(name).join("init.lua")))
689 .unwrap_or_default();
690 results.push(SearchResult {
691 name: name.clone(),
692 version: version.clone().unwrap_or_default(),
693 description: String::new(),
694 category: String::new(),
695 source: String::new(),
696 installed: true,
697 card_count: card_counts.get(name).copied().unwrap_or(0),
698 best_card: None,
699 docstring,
700 });
701 }
702
703 results
704}
705
706fn matches_query(result: &SearchResult, query: &str) -> bool {
709 let q = query.to_lowercase();
710 result.name.to_lowercase().contains(&q)
711 || result.description.to_lowercase().contains(&q)
712 || result.category.to_lowercase().contains(&q)
713 || result.docstring.to_lowercase().contains(&q)
714}
715
716fn extract_docstring(path: &std::path::Path) -> String {
725 let content = match std::fs::read_to_string(path) {
726 Ok(c) => c,
727 Err(_) => return String::new(),
728 };
729 let mut lines = Vec::new();
730 for line in content.lines() {
731 let trimmed = line.trim_start();
732 if let Some(rest) = trimmed.strip_prefix("---") {
733 lines.push(rest.trim().to_string());
734 } else if trimmed.is_empty() {
735 continue;
737 } else {
738 break;
739 }
740 }
741 lines.join("\n")
742}
743
744fn parse_meta_from_init_lua(path: &std::path::Path) -> Option<(String, String, String, String)> {
757 let content = std::fs::read_to_string(path).ok()?;
758 let head = content.as_str();
759
760 let mut search_from = 0;
764 let meta_start = loop {
765 let rel = head[search_from..].find("M.meta")?;
766 let pos = search_from + rel;
767 let line_start = head[..pos].rfind('\n').map(|i| i + 1).unwrap_or(0);
768 if !head[line_start..pos].contains("--") {
769 break pos;
770 }
771 search_from = pos + "M.meta".len();
772 };
773 let brace_start = head[meta_start..].find('{')? + meta_start;
774
775 let mut depth = 0;
777 let mut brace_end = None;
778 for (i, ch) in head[brace_start..].char_indices() {
779 match ch {
780 '{' => depth += 1,
781 '}' => {
782 depth -= 1;
783 if depth == 0 {
784 brace_end = Some(brace_start + i);
785 break;
786 }
787 }
788 _ => {}
789 }
790 }
791 let brace_end = brace_end?;
792 let block = &head[brace_start + 1..brace_end];
793
794 let extract = |field: &str| -> String {
795 let mut search_from = 0;
801 while let Some(rel) = block[search_from..].find(field) {
802 let pos = search_from + rel;
803 let word_boundary = pos == 0 || {
804 let prev = block.as_bytes()[pos - 1];
805 !(prev.is_ascii_alphanumeric() || prev == b'_')
806 };
807 if word_boundary {
808 let after = &block[pos + field.len()..];
809 let mut collected = String::new();
810 let mut cursor = 0usize;
811 let mut found_any = false;
812 loop {
813 let rest = &after[cursor..];
814 let Some(q_start_rel) = rest.find('"') else {
815 break;
816 };
817 if found_any {
818 let between = &rest[..q_start_rel];
823 if between.trim() != ".." {
824 break;
825 }
826 }
827 let lit_start = cursor + q_start_rel + 1;
828 let Some(q_end_rel) = after[lit_start..].find('"') else {
829 break;
830 };
831 collected.push_str(&after[lit_start..lit_start + q_end_rel]);
832 cursor = lit_start + q_end_rel + 1;
833 found_any = true;
834 }
835 if found_any {
836 return collected;
837 }
838 }
839 search_from = pos + field.len();
840 }
841 String::new()
842 };
843
844 let name = extract("name");
845 if name.is_empty() {
846 return None;
847 }
848 Some((
849 name,
850 extract("version"),
851 extract("description"),
852 extract("category"),
853 ))
854}
855
856fn build_index(source_dir: Option<&std::path::Path>) -> HubIndex {
865 let empty = || HubIndex {
866 schema_version: "hub_index/v0".into(),
867 updated_at: super::manifest::now_iso8601(),
868 packages: Vec::new(),
869 };
870
871 let pkg_dir = match source_dir {
872 Some(d) => d.to_path_buf(),
873 None => {
874 let home = match dirs::home_dir() {
875 Some(h) => h,
876 None => return empty(),
877 };
878 home.join(".algocline").join("packages")
879 }
880 };
881
882 let use_local_state = source_dir.is_none();
883 let card_counts = if use_local_state {
884 local_card_counts()
885 } else {
886 HashMap::new()
887 };
888 let manifest = if use_local_state {
889 manifest::load_manifest().unwrap_or_default()
890 } else {
891 manifest::Manifest::default()
892 };
893
894 let mut entries = Vec::new();
895
896 let dir_entries = match std::fs::read_dir(&pkg_dir) {
897 Ok(e) => e,
898 Err(_) => return empty(),
899 };
900
901 for entry in dir_entries.flatten() {
902 if !entry.path().is_dir() {
903 continue;
904 }
905 let dir_name = match entry.file_name().to_str() {
906 Some(n) if !n.starts_with('.') && !n.starts_with('_') => n.to_string(),
907 _ => continue,
908 };
909
910 let init_lua = entry.path().join("init.lua");
911 if !init_lua.exists() {
912 continue;
913 }
914
915 let (name, version, description, category) = parse_meta_from_init_lua(&init_lua)
916 .unwrap_or_else(|| {
917 (
918 dir_name.clone(),
919 String::new(),
920 String::new(),
921 String::new(),
922 )
923 });
924
925 let docstring = extract_docstring(&init_lua);
926
927 let source = manifest
929 .packages
930 .get(&dir_name)
931 .map(|e| e.source.clone())
932 .unwrap_or_default();
933
934 entries.push(IndexEntry {
935 name,
936 version,
937 description,
938 category,
939 source,
940 card_count: card_counts.get(&dir_name).copied().unwrap_or(0),
941 best_card: None,
942 docstring,
943 });
944 }
945
946 entries.sort_by(|a, b| a.name.cmp(&b.name));
947
948 HubIndex {
949 schema_version: "hub_index/v0".into(),
950 updated_at: super::manifest::now_iso8601(),
951 packages: entries,
952 }
953}
954
955impl AppService {
958 pub fn hub_reindex(
967 &self,
968 output_path: Option<&str>,
969 source_dir: Option<&str>,
970 ) -> Result<String, String> {
971 let src = source_dir.map(std::path::Path::new);
972 if let Some(d) = src {
973 if !d.is_dir() {
974 return Err(format!("source_dir '{}' is not a directory", d.display()));
975 }
976 }
977 let index = build_index(src);
978
979 let written_path = if let Some(path) = output_path {
980 let json = serde_json::to_string_pretty(&index)
981 .map_err(|e| format!("Failed to serialize index: {e}"))?;
982 std::fs::write(path, &json)
983 .map_err(|e| format!("Failed to write index to {path}: {e}"))?;
984 Some(path.to_string())
985 } else {
986 None
987 };
988
989 let response = serde_json::json!({
990 "package_count": index.packages.len(),
991 "updated_at": index.updated_at,
992 "output_path": written_path,
993 "source_dir": source_dir,
994 });
995 Ok(response.to_string())
996 }
997
998 pub fn hub_info(&self, pkg: &str) -> Result<String, String> {
1003 use algocline_engine::card;
1004
1005 if pkg.contains("..") || pkg.contains('/') || pkg.contains('\\') {
1007 return Err(format!("Invalid package name: '{pkg}'"));
1008 }
1009
1010 let installed = installed_packages();
1012 let is_installed = installed.contains_key(pkg);
1013
1014 let (version, description, category, source) = {
1015 let (remote, _) = fetch_remote_indices();
1017 if let Some(entry) = remote.packages.iter().find(|e| e.name == pkg) {
1018 (
1019 entry.version.clone(),
1020 entry.description.clone(),
1021 entry.category.clone(),
1022 entry.source.clone(),
1023 )
1024 } else if is_installed {
1025 let home = dirs::home_dir().ok_or("Cannot determine home directory")?;
1027 let init_lua = home
1028 .join(".algocline")
1029 .join("packages")
1030 .join(pkg)
1031 .join("init.lua");
1032 let meta = parse_meta_from_init_lua(&init_lua);
1033 let manifest_source = manifest::load_manifest()
1034 .ok()
1035 .and_then(|m| m.packages.get(pkg).map(|e| e.source.clone()))
1036 .unwrap_or_default();
1037 match meta {
1038 Some((_, v, d, c)) => (v, d, c, manifest_source),
1039 None => (
1040 installed.get(pkg).cloned().flatten().unwrap_or_default(),
1041 String::new(),
1042 String::new(),
1043 manifest_source,
1044 ),
1045 }
1046 } else {
1047 return Err(format!(
1048 "Package '{pkg}' not found in remote indices or locally installed packages"
1049 ));
1050 }
1051 };
1052
1053 let card_rows = card::list(Some(pkg)).unwrap_or_default();
1055 let cards_json = card::summaries_to_json(&card_rows);
1056
1057 let aliases_json = match card::alias_list(Some(pkg)) {
1059 Ok(rows) => card::aliases_to_json(&rows),
1060 Err(_) => serde_json::json!([]),
1061 };
1062
1063 let card_count = card_rows.len();
1065 let best_pass_rate = card_rows
1066 .iter()
1067 .filter_map(|c| c.pass_rate)
1068 .fold(f64::NEG_INFINITY, f64::max);
1069 let best_pass_rate = if best_pass_rate.is_finite() {
1070 Some(best_pass_rate)
1071 } else {
1072 None
1073 };
1074
1075 let eval_count = count_evals_for_pkg(pkg);
1077
1078 let response = serde_json::json!({
1079 "pkg": {
1080 "name": pkg,
1081 "version": version,
1082 "description": description,
1083 "category": category,
1084 "source": source,
1085 "installed": is_installed,
1086 },
1087 "cards": cards_json,
1088 "aliases": aliases_json,
1089 "stats": {
1090 "card_count": card_count,
1091 "eval_count": eval_count,
1092 "best_pass_rate": best_pass_rate,
1093 },
1094 });
1095 Ok(response.to_string())
1096 }
1097
1098 pub fn hub_search(
1103 &self,
1104 query: Option<&str>,
1105 category: Option<&str>,
1106 installed_only: Option<bool>,
1107 limit: Option<usize>,
1108 ) -> Result<String, String> {
1109 let (remote, warnings) = fetch_remote_indices();
1110 let mut results = merge(&remote);
1111
1112 if let Some(q) = query {
1114 if !q.is_empty() {
1115 results.retain(|r| matches_query(r, q));
1116 }
1117 }
1118
1119 if let Some(cat) = category {
1121 let cat_lower = cat.to_lowercase();
1122 results.retain(|r| r.category.to_lowercase() == cat_lower);
1123 }
1124
1125 if let Some(true) = installed_only {
1127 results.retain(|r| r.installed);
1128 }
1129
1130 results.sort_by(|a, b| {
1132 b.installed
1133 .cmp(&a.installed)
1134 .then_with(|| a.name.cmp(&b.name))
1135 });
1136
1137 let total = results.len();
1139 let limit = limit.unwrap_or(50);
1140 results.truncate(limit);
1141
1142 let sources = discover_index_urls();
1144
1145 let mut json = serde_json::json!({
1146 "results": results,
1147 "total": total,
1148 "sources": sources,
1149 });
1150 if !warnings.is_empty() {
1151 json["warnings"] = serde_json::json!(warnings);
1152 }
1153 Ok(json.to_string())
1154 }
1155}
1156
1157#[cfg(test)]
1158mod tests {
1159 use super::*;
1160
1161 #[test]
1162 fn repo_to_index_url_github() {
1163 assert_eq!(
1164 repo_to_index_url("https://github.com/ynishi/algocline-bundled-packages"),
1165 Some(
1166 "https://raw.githubusercontent.com/ynishi/algocline-bundled-packages/main/hub_index.json"
1167 .to_string()
1168 )
1169 );
1170 }
1171
1172 #[test]
1173 fn repo_to_index_url_github_trailing_slash() {
1174 assert_eq!(
1175 repo_to_index_url("https://github.com/user/repo/"),
1176 Some("https://raw.githubusercontent.com/user/repo/main/hub_index.json".to_string())
1177 );
1178 }
1179
1180 #[test]
1181 fn repo_to_index_url_github_dot_git() {
1182 assert_eq!(
1183 repo_to_index_url("https://github.com/user/repo.git"),
1184 Some("https://raw.githubusercontent.com/user/repo/main/hub_index.json".to_string())
1185 );
1186 }
1187
1188 #[test]
1189 fn repo_to_index_url_direct_json() {
1190 assert_eq!(
1191 repo_to_index_url("https://example.com/my_index.json"),
1192 Some("https://example.com/my_index.json".to_string())
1193 );
1194 }
1195
1196 #[test]
1197 fn repo_to_index_url_unknown_host_no_json() {
1198 assert_eq!(repo_to_index_url("https://example.com/some-repo"), None);
1199 }
1200
1201 #[test]
1202 fn repo_to_index_url_local_path() {
1203 assert_eq!(repo_to_index_url("/home/user/my-pkg"), None);
1204 }
1205
1206 #[test]
1207 fn cache_key_stable() {
1208 let k1 = cache_key("https://example.com/index.json");
1209 let k2 = cache_key("https://example.com/index.json");
1210 assert_eq!(k1, k2);
1211 assert_eq!(k1.len(), 16); }
1213
1214 #[test]
1215 fn cache_key_different_urls() {
1216 let k1 = cache_key("https://a.com/index.json");
1217 let k2 = cache_key("https://b.com/index.json");
1218 assert_ne!(k1, k2);
1219 }
1220
1221 #[test]
1222 fn parse_meta_flat() {
1223 let tmp = tempfile::tempdir().unwrap();
1224 let path = tmp.path().join("init.lua");
1225 std::fs::write(
1226 &path,
1227 r#"
1228local M = {}
1229M.meta = {
1230 name = "my_pkg",
1231 version = "1.0.0",
1232 description = "A test package",
1233 category = "reasoning",
1234}
1235return M
1236"#,
1237 )
1238 .unwrap();
1239
1240 let result = parse_meta_from_init_lua(&path).unwrap();
1241 assert_eq!(result.0, "my_pkg");
1242 assert_eq!(result.1, "1.0.0");
1243 assert_eq!(result.2, "A test package");
1244 assert_eq!(result.3, "reasoning");
1245 }
1246
1247 #[test]
1248 fn parse_meta_nested_table() {
1249 let tmp = tempfile::tempdir().unwrap();
1250 let path = tmp.path().join("init.lua");
1251 std::fs::write(
1252 &path,
1253 r#"
1254local M = {}
1255M.meta = {
1256 name = "nested_pkg",
1257 tags = { "a", "b" },
1258 description = "After nested",
1259}
1260return M
1261"#,
1262 )
1263 .unwrap();
1264
1265 let result = parse_meta_from_init_lua(&path).unwrap();
1266 assert_eq!(result.0, "nested_pkg");
1267 assert_eq!(result.2, "After nested");
1268 }
1269
1270 #[test]
1274 #[ignore]
1275 fn parse_meta_real_bundled_packages() {
1276 let Ok(root) = std::env::var("BUNDLED_PACKAGES_DIR") else {
1277 panic!("set BUNDLED_PACKAGES_DIR=/path/to/algocline-bundled-packages");
1278 };
1279 let root = std::path::Path::new(&root);
1280 let mut total = 0usize;
1281 let mut failed_parse: Vec<String> = Vec::new();
1282 let mut empty_desc: Vec<String> = Vec::new();
1283 for entry in std::fs::read_dir(root).unwrap().flatten() {
1284 if !entry.path().is_dir() {
1285 continue;
1286 }
1287 let name = entry.file_name().to_string_lossy().to_string();
1288 if name.starts_with('.') || name.starts_with('_') {
1289 continue;
1290 }
1291 let init_lua = entry.path().join("init.lua");
1292 if !init_lua.exists() {
1293 continue;
1294 }
1295 total += 1;
1296 match parse_meta_from_init_lua(&init_lua) {
1297 Some((_n, _v, desc, _c)) => {
1298 if desc.is_empty() {
1299 empty_desc.push(name);
1300 }
1301 }
1302 None => failed_parse.push(name),
1303 }
1304 }
1305 assert!(total >= 100, "expected ≥100 pkgs, got {total}");
1306 assert!(
1307 failed_parse.is_empty(),
1308 "parse_meta returned None for {} pkgs: {:?}",
1309 failed_parse.len(),
1310 failed_parse
1311 );
1312 assert!(
1313 empty_desc.is_empty(),
1314 "empty description for {} pkgs: {:?}",
1315 empty_desc.len(),
1316 empty_desc
1317 );
1318 }
1319
1320 #[test]
1321 fn parse_meta_concat_string_literals() {
1322 let tmp = tempfile::tempdir().unwrap();
1324 let path = tmp.path().join("init.lua");
1325 std::fs::write(
1326 &path,
1327 r#"
1328local M = {}
1329M.meta = {
1330 name = "concat_pkg",
1331 version = "0.1.0",
1332 description = "Adaptive Branching MCTS — Thompson Sampling with dynamic "
1333 .. "wider/deeper decisions. GEN node mechanism for principled branching. "
1334 .. "Consistently outperforms standard MCTS and repeated sampling.",
1335 category = "reasoning",
1336}
1337return M
1338"#,
1339 )
1340 .unwrap();
1341
1342 let result = parse_meta_from_init_lua(&path).unwrap();
1343 assert_eq!(result.0, "concat_pkg");
1344 assert_eq!(result.1, "0.1.0");
1345 assert_eq!(
1346 result.2,
1347 "Adaptive Branching MCTS — Thompson Sampling with dynamic \
1348 wider/deeper decisions. GEN node mechanism for principled branching. \
1349 Consistently outperforms standard MCTS and repeated sampling."
1350 );
1351 assert_eq!(result.3, "reasoning");
1352 }
1353
1354 #[test]
1355 fn parse_meta_large_leading_docstring() {
1356 let tmp = tempfile::tempdir().unwrap();
1358 let path = tmp.path().join("init.lua");
1359 let mut content = String::new();
1360 for i in 0..120 {
1362 content.push_str(&format!(
1363 "--- line {i}: this is a long documentation comment to push M.meta beyond the old 2KB scan window\n"
1364 ));
1365 }
1366 content.push_str(
1367 r#"
1368local M = {}
1369M.meta = {
1370 name = "late_meta_pkg",
1371 version = "0.2.0",
1372 description = "Located past 2KB",
1373 category = "test",
1374}
1375return M
1376"#,
1377 );
1378 std::fs::write(&path, &content).unwrap();
1379 assert!(content.len() > 2048, "fixture should exceed 2KB");
1380
1381 let result = parse_meta_from_init_lua(&path).unwrap();
1382 assert_eq!(result.0, "late_meta_pkg");
1383 assert_eq!(result.1, "0.2.0");
1384 assert_eq!(result.2, "Located past 2KB");
1385 assert_eq!(result.3, "test");
1386 }
1387
1388 #[test]
1389 fn parse_meta_word_boundary() {
1390 let tmp = tempfile::tempdir().unwrap();
1391 let path = tmp.path().join("init.lua");
1392 std::fs::write(
1393 &path,
1394 r#"
1395local M = {}
1396M.meta = {
1397 name = "wb_pkg",
1398 short_description = "should not match",
1399 description = "correct one",
1400}
1401return M
1402"#,
1403 )
1404 .unwrap();
1405
1406 let result = parse_meta_from_init_lua(&path).unwrap();
1407 assert_eq!(result.0, "wb_pkg");
1408 assert_eq!(result.2, "correct one");
1409 }
1410
1411 #[test]
1412 fn merge_dedup_uses_hashset() {
1413 let remote = HubIndex {
1416 schema_version: "hub_index/v0".into(),
1417 updated_at: String::new(),
1418 packages: vec![IndexEntry {
1419 name: "remote_only".into(),
1420 version: "1.0".into(),
1421 description: "from remote".into(),
1422 category: "test".into(),
1423 source: String::new(),
1424 card_count: 0,
1425 best_card: None,
1426 docstring: String::new(),
1427 }],
1428 };
1429
1430 let results = merge(&remote);
1431 assert!(results.iter().any(|r| r.name == "remote_only"));
1433 }
1434
1435 #[test]
1436 fn extract_docstring_collects_leading_comments() {
1437 let tmp = tempfile::tempdir().unwrap();
1438 let path = tmp.path().join("init.lua");
1439 std::fs::write(
1440 &path,
1441 r#"--- cascade — Multi-level difficulty routing with confidence gating
1442--- Based on: "FrugalGPT" (Chen et al., 2023)
1443--- Uses Thompson Sampling for budget allocation.
1444
1445local M = {}
1446M.meta = { name = "cascade" }
1447return M
1448"#,
1449 )
1450 .unwrap();
1451
1452 let doc = extract_docstring(&path);
1453 assert!(doc.contains("FrugalGPT"), "should contain paper ref");
1454 assert!(
1455 doc.contains("Thompson Sampling"),
1456 "should contain technique"
1457 );
1458 assert!(!doc.contains("local M"), "should not contain code");
1459 }
1460
1461 #[test]
1462 fn extract_docstring_empty_when_no_comments() {
1463 let tmp = tempfile::tempdir().unwrap();
1464 let path = tmp.path().join("init.lua");
1465 std::fs::write(&path, "local M = {}\nreturn M\n").unwrap();
1466
1467 let doc = extract_docstring(&path);
1468 assert!(doc.is_empty());
1469 }
1470
1471 #[test]
1472 fn matches_query_searches_docstring() {
1473 let result = SearchResult {
1474 name: "cascade".into(),
1475 version: "0.1.0".into(),
1476 description: "Multi-level routing".into(),
1477 category: "meta".into(),
1478 source: String::new(),
1479 installed: true,
1480 card_count: 0,
1481 best_card: None,
1482 docstring: "Based on FrugalGPT. Uses Thompson Sampling.".into(),
1483 };
1484
1485 assert!(matches_query(&result, "thompson"), "docstring match");
1486 assert!(matches_query(&result, "FrugalGPT"), "docstring match case");
1487 assert!(matches_query(&result, "routing"), "description match");
1488 assert!(!matches_query(&result, "bayesian"), "no match");
1489 }
1490}