1use std::collections::{HashMap, HashSet};
77use std::path::PathBuf;
78
79use serde::{Deserialize, Serialize};
80
81use algocline_core::{AppDir, PkgEntity};
82
83use super::list_opts::{
84 apply_sort_by_value, matches_filter, parse_sort, project_fields, resolve_fields, ListOpts,
85 HUB_SEARCH_FULL, HUB_SEARCH_SUMMARY,
86};
87use super::manifest;
88use super::resolve::AUTO_INSTALL_SOURCES;
89use super::source::PackageSource;
90use super::AppService;
91
92const CACHE_TTL_SECS: u64 = 3600;
96
97const HTTP_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(30);
99
100#[derive(Debug, Clone, Serialize, Deserialize)]
104pub(crate) struct HubIndex {
105 pub schema_version: String,
106 #[serde(default)]
107 pub updated_at: String,
108 #[serde(default)]
109 pub packages: Vec<IndexEntry>,
110}
111
112#[derive(Debug, Clone, Serialize, Deserialize)]
120pub(crate) struct IndexEntry {
121 #[serde(flatten)]
122 pub entity: PkgEntity,
123 #[serde(default)]
127 pub source: PackageSource,
128 #[serde(default)]
129 pub card_count: usize,
130 #[serde(default)]
131 pub best_card: Option<BestCard>,
132}
133
134#[derive(Debug, Clone, Serialize, Deserialize)]
136pub(crate) struct BestCard {
137 pub card_id: String,
138 #[serde(default)]
139 pub model: String,
140 #[serde(default)]
141 pub pass_rate: f64,
142 #[serde(default)]
143 pub scenario: String,
144}
145
146#[derive(Debug, Clone, Serialize)]
167struct SearchResult {
168 #[serde(flatten, serialize_with = "serialize_entity_without_docstring")]
169 entity: PkgEntity,
170 source: PackageSource,
172 installed: bool,
173 card_count: usize,
174 best_card: Option<BestCard>,
175 #[serde(skip_serializing_if = "Option::is_none")]
176 docstring_matched: Option<bool>,
177}
178
179fn serialize_entity_without_docstring<S>(entity: &PkgEntity, ser: S) -> Result<S::Ok, S::Error>
184where
185 S: serde::Serializer,
186{
187 use serde::ser::SerializeMap;
188 let mut map = ser.serialize_map(Some(4))?;
189 map.serialize_entry("name", &entity.name)?;
190 map.serialize_entry("version", &entity.version)?;
191 map.serialize_entry("description", &entity.description)?;
192 map.serialize_entry("category", &entity.category)?;
193 map.end()
194}
195
196impl SearchResult {
197 fn to_value_with_optional_docstring(&self, include_docstring: bool) -> serde_json::Value {
210 let mut v = serde_json::to_value(self).unwrap_or(serde_json::Value::Null);
211 if include_docstring {
212 if let serde_json::Value::Object(ref mut map) = v {
213 let doc = self.entity.docstring.clone().unwrap_or_default();
214 map.insert("docstring".to_string(), serde_json::Value::String(doc));
215 }
216 }
217 v
218 }
219}
220
221#[derive(Debug, Clone, Serialize, Deserialize)]
230pub(crate) struct RegistryEntry {
231 pub source: String,
233 pub origin: String,
235 pub added_at: String,
237}
238
239#[derive(Debug, Clone, Serialize, Deserialize, Default)]
241pub(crate) struct HubRegistries {
242 pub registries: Vec<RegistryEntry>,
243}
244
245fn registries_path(app_dir: &AppDir) -> PathBuf {
246 app_dir.hub_registries_json()
247}
248
249fn load_registries(app_dir: &AppDir) -> HubRegistries {
251 let path = registries_path(app_dir);
252 if !path.exists() {
253 return HubRegistries::default();
254 }
255 std::fs::read_to_string(&path)
256 .ok()
257 .and_then(|c| serde_json::from_str(&c).ok())
258 .unwrap_or_default()
259}
260
261pub(crate) fn register_source(app_dir: &AppDir, source: &str, origin: &str) -> Result<(), String> {
275 let normalized = source.trim_end_matches('/').to_string();
276 if normalized.is_empty() {
277 return Ok(());
278 }
279 if normalized.starts_with('/') || normalized.starts_with('.') {
281 return Ok(());
282 }
283
284 let path = registries_path(app_dir);
285 if let Some(parent) = path.parent() {
286 std::fs::create_dir_all(parent).map_err(|e| {
287 format!(
288 "failed to create hub registries dir {}: {e}",
289 parent.display()
290 )
291 })?;
292 }
293
294 let mut reg = load_registries(app_dir);
296
297 if reg
299 .registries
300 .iter()
301 .any(|e| e.source.trim_end_matches('/') == normalized)
302 {
303 return Ok(());
304 }
305
306 reg.registries.push(RegistryEntry {
307 source: normalized,
308 origin: origin.to_string(),
309 added_at: manifest::now_iso8601(),
310 });
311
312 let json = serde_json::to_string_pretty(®)
314 .map_err(|e| format!("failed to serialize hub registries: {e}"))?;
315 let tmp_path = path.with_extension("json.tmp");
316 std::fs::write(&tmp_path, &json).map_err(|e| {
317 format!(
318 "failed to write hub registries tmp {}: {e}",
319 tmp_path.display()
320 )
321 })?;
322 std::fs::rename(&tmp_path, &path).map_err(|e| {
323 let _ = std::fs::remove_file(&tmp_path);
325 format!(
326 "failed to atomically rename hub registries onto {}: {e}",
327 path.display()
328 )
329 })
330}
331
332fn collection_url_from_config(app_dir: &AppDir) -> Option<String> {
344 let path = app_dir.config_toml();
345 let content = std::fs::read_to_string(&path).ok()?;
346 let doc: toml_edit::DocumentMut = content.parse().ok()?;
347 let url = doc
348 .get("hub")?
349 .get("collection_url")?
350 .as_str()?
351 .trim()
352 .to_string();
353 if url.is_empty() {
354 None
355 } else {
356 Some(url)
357 }
358}
359
360fn repo_to_index_url(repo_url: &str) -> Option<String> {
375 let trimmed = repo_url.trim_end_matches('/').trim_end_matches(".git");
376 if let Some(path) = trimmed.strip_prefix("https://github.com/") {
377 let parts: Vec<&str> = path.splitn(3, '/').collect();
379 if parts.len() >= 2 {
380 return Some(format!(
381 "https://raw.githubusercontent.com/{}/{}/main/hub_index.json",
382 parts[0], parts[1]
383 ));
384 }
385 }
386 if trimmed.ends_with(".json") {
388 Some(trimmed.to_string())
389 } else {
390 None
391 }
392}
393
394fn discover_index_urls(app_dir: &AppDir) -> Result<Vec<String>, String> {
402 let mut index_urls: Vec<String> = Vec::new();
403
404 if let Some(url) = collection_url_from_config(app_dir) {
406 index_urls.push(url);
407 }
408
409 let mut repo_urls: HashSet<String> = HashSet::new();
410
411 let reg = load_registries(app_dir);
413 for entry in ®.registries {
414 let normalized = entry.source.trim_end_matches('/').to_string();
415 if !normalized.is_empty() {
416 repo_urls.insert(normalized);
417 }
418 }
419
420 let m = manifest::load_manifest(app_dir)?;
424 for entry in m.packages.values() {
425 if let Some(url) = entry.source.git_url() {
426 let normalized = url.trim_end_matches('/').to_string();
427 if !normalized.is_empty() {
428 repo_urls.insert(normalized);
429 }
430 }
431 }
432
433 for url in AUTO_INSTALL_SOURCES {
435 repo_urls.insert(url.to_string());
436 }
437
438 let existing: HashSet<String> = index_urls.iter().cloned().collect();
440 let mut derived: Vec<String> = repo_urls
441 .iter()
442 .filter_map(|url| repo_to_index_url(url))
443 .filter(|url| !existing.contains(url))
444 .collect();
445 derived.sort();
446 derived.dedup();
447 index_urls.extend(derived);
448
449 Ok(index_urls)
450}
451
452fn cache_dir(app_dir: &AppDir) -> PathBuf {
460 app_dir.hub_cache_dir()
461}
462
463fn cache_key(url: &str) -> String {
464 let mut h: u64 = 0xcbf2_9ce4_8422_2325; for b in url.as_bytes() {
468 h ^= *b as u64;
469 h = h.wrapping_mul(0x0100_0000_01b3); }
471 format!("{h:016x}")
472}
473
474fn load_cached(app_dir: &AppDir, url: &str) -> Option<HubIndex> {
476 let dir = cache_dir(app_dir);
477 let path = dir.join(format!("{}.json", cache_key(url)));
478 if !path.exists() {
479 return None;
480 }
481 let metadata = std::fs::metadata(&path).ok()?;
482 let age = metadata.modified().ok()?.elapsed().ok()?;
483 if age.as_secs() > CACHE_TTL_SECS {
484 return None;
485 }
486 let content = std::fs::read_to_string(&path).ok()?;
487 serde_json::from_str(&content).ok()
488}
489
490fn save_cached(app_dir: &AppDir, url: &str, index: &HubIndex) -> Result<(), String> {
497 let dir = cache_dir(app_dir);
498 std::fs::create_dir_all(&dir)
499 .map_err(|e| format!("failed to create hub cache dir {}: {e}", dir.display()))?;
500 let path = dir.join(format!("{}.json", cache_key(url)));
501 let json = serde_json::to_string_pretty(index)
502 .map_err(|e| format!("failed to serialize hub cache: {e}"))?;
503 std::fs::write(&path, json)
504 .map_err(|e| format!("failed to write hub cache {}: {e}", path.display()))
505}
506
507fn fetch_one(app_dir: &AppDir, url: &str) -> Result<(HubIndex, Option<String>), String> {
517 if let Some(cached) = load_cached(app_dir, url) {
518 return Ok((cached, None));
519 }
520
521 let agent = ureq::Agent::new_with_config(
522 ureq::config::Config::builder()
523 .timeout_global(Some(HTTP_TIMEOUT))
524 .build(),
525 );
526 let body: String = agent
527 .get(url)
528 .call()
529 .map_err(|e| format!("Failed to fetch {url}: {e}"))?
530 .body_mut()
531 .read_to_string()
532 .map_err(|e| format!("Failed to read response from {url}: {e}"))?;
533
534 let index: HubIndex = serde_json::from_str(&body)
535 .map_err(|e| format!("Failed to parse index from {url}: {e}"))?;
536
537 let cache_warning = save_cached(app_dir, url, &index)
538 .err()
539 .map(|e| format!("hub cache write for {url}: {e}"));
540 Ok((index, cache_warning))
541}
542
543fn fetch_remote_indices(app_dir: &AppDir) -> Result<(HubIndex, Vec<String>), String> {
546 let urls = discover_index_urls(app_dir)?;
547 let mut all_packages: Vec<IndexEntry> = Vec::new();
548 let mut seen_names: HashSet<String> = HashSet::new();
549 let mut warnings: Vec<String> = Vec::new();
550
551 for url in &urls {
552 match fetch_one(app_dir, url) {
553 Ok((index, cache_warning)) => {
554 for entry in index.packages {
555 if seen_names.insert(entry.entity.name.clone()) {
556 all_packages.push(entry);
557 }
558 }
560 if let Some(w) = cache_warning {
561 warnings.push(w);
562 }
563 }
564 Err(e) => {
565 warnings.push(e);
566 }
567 }
568 }
569
570 if all_packages.is_empty() && !warnings.is_empty() {
571 warnings.insert(
572 0,
573 "all remote indices unavailable, showing local packages only".to_string(),
574 );
575 }
576
577 let merged = HubIndex {
578 schema_version: "hub_index/v0".into(),
579 updated_at: String::new(),
580 packages: all_packages,
581 };
582 Ok((merged, warnings))
583}
584
585fn installed_packages(app_dir: &AppDir) -> Result<HashMap<String, Option<String>>, String> {
590 let mut map = HashMap::new();
591
592 let m = manifest::load_manifest(app_dir)?;
594 for (name, entry) in &m.packages {
595 map.insert(name.clone(), entry.version.clone());
596 }
597
598 let pkg_dir = app_dir.packages_dir();
600 if let Ok(entries) = std::fs::read_dir(&pkg_dir) {
601 for entry in entries.flatten() {
602 if entry.path().is_dir() {
603 if let Some(name) = entry.file_name().to_str() {
604 map.entry(name.to_string()).or_insert(None);
605 }
606 }
607 }
608 }
609
610 Ok(map)
611}
612
613fn local_card_counts(app_dir: &AppDir) -> HashMap<String, usize> {
615 let mut map = HashMap::new();
616 let cards_dir = app_dir.cards_dir();
617 let entries = match std::fs::read_dir(&cards_dir) {
618 Ok(e) => e,
619 Err(_) => return map,
620 };
621 for entry in entries.flatten() {
622 if !entry.path().is_dir() {
623 continue;
624 }
625 let pkg = match entry.file_name().to_str() {
626 Some(n) => n.to_string(),
627 None => continue,
628 };
629 let count = std::fs::read_dir(entry.path())
630 .map(|es| {
631 es.flatten()
632 .filter(|e| e.path().extension().is_some_and(|ext| ext == "toml"))
633 .count()
634 })
635 .unwrap_or(0);
636 if count > 0 {
637 map.insert(pkg, count);
638 }
639 }
640 map
641}
642
643fn count_evals_for_pkg(app_dir: &AppDir, pkg: &str) -> usize {
648 let evals_dir = app_dir.evals_dir();
649 let entries = match std::fs::read_dir(&evals_dir) {
650 Ok(e) => e,
651 Err(_) => return 0,
652 };
653
654 let mut meta_stems: HashSet<String> = HashSet::new();
657 let mut meta_matches: usize = 0;
658 let mut non_meta_paths: Vec<(PathBuf, String)> = Vec::new(); for entry in entries.flatten() {
661 let path = entry.path();
662 let name = match path.file_name().and_then(|n| n.to_str()) {
663 Some(n) => n.to_string(),
664 None => continue,
665 };
666
667 if name.ends_with(".meta.json") {
668 let stem = name.trim_end_matches(".meta.json").to_string();
669 meta_stems.insert(stem);
670 if let Ok(content) = std::fs::read_to_string(&path) {
671 if let Ok(val) = serde_json::from_str::<serde_json::Value>(&content) {
672 if val.get("strategy").and_then(|s| s.as_str()) == Some(pkg) {
673 meta_matches += 1;
674 }
675 }
676 }
677 continue;
678 }
679
680 if !name.ends_with(".json") || name.starts_with("compare_") {
682 continue;
683 }
684
685 let stem = path
686 .file_stem()
687 .and_then(|s| s.to_str())
688 .unwrap_or("")
689 .to_string();
690 non_meta_paths.push((path, stem));
691 }
692
693 let fallback_matches = non_meta_paths
695 .iter()
696 .filter(|(_, stem)| !meta_stems.contains(stem))
697 .filter(|(path, _)| {
698 std::fs::read_to_string(path)
699 .ok()
700 .and_then(|c| serde_json::from_str::<serde_json::Value>(&c).ok())
701 .and_then(|v| v.get("strategy")?.as_str().map(|s| s == pkg))
702 .unwrap_or(false)
703 })
704 .count();
705
706 meta_matches + fallback_matches
707}
708
709fn merge(app_dir: &AppDir, remote: &HubIndex) -> Result<Vec<SearchResult>, String> {
717 let installed = installed_packages(app_dir)?;
718 let card_counts = local_card_counts(app_dir);
719 let pkg_dir: Option<PathBuf> = Some(app_dir.packages_dir());
720
721 let mut seen: HashSet<String> = HashSet::new();
722 let mut results: Vec<SearchResult> = Vec::new();
723
724 for entry in &remote.packages {
725 let pkg_name = &entry.entity.name;
726 let is_installed = installed.contains_key(pkg_name);
727 let local_cards = card_counts.get(pkg_name).copied().unwrap_or(0);
728
729 let docstring = if entry.entity.docstring.as_deref().unwrap_or("").is_empty()
733 && is_installed
734 {
735 pkg_dir
736 .as_ref()
737 .and_then(|d| PkgEntity::parse_from_init_lua(&d.join(pkg_name).join("init.lua")))
738 .and_then(|e| e.docstring)
739 } else {
740 entry.entity.docstring.clone()
741 };
742
743 seen.insert(pkg_name.clone());
744 let mut merged_entity = entry.entity.clone();
745 merged_entity.docstring = docstring;
746 results.push(SearchResult {
747 entity: merged_entity,
748 source: entry.source.clone(),
749 installed: is_installed,
750 card_count: if is_installed && local_cards > entry.card_count {
751 local_cards
752 } else {
753 entry.card_count
754 },
755 best_card: entry.best_card.clone(),
756 docstring_matched: None,
757 });
758 }
759
760 for (name, version) in &installed {
762 if seen.contains(name) {
763 continue;
764 }
765 let parsed_entity = pkg_dir
772 .as_ref()
773 .and_then(|d| PkgEntity::parse_from_init_lua(&d.join(name).join("init.lua")));
774 let entity = parsed_entity.unwrap_or(PkgEntity {
775 name: name.clone(),
776 version: version.clone(),
777 description: None,
778 category: None,
779 docstring: None,
780 });
781 results.push(SearchResult {
782 entity,
783 source: PackageSource::Unknown,
784 installed: true,
785 card_count: card_counts.get(name).copied().unwrap_or(0),
786 best_card: None,
787 docstring_matched: None,
788 });
789 }
790
791 Ok(results)
792}
793
794fn matches_query(result: &SearchResult, query: &str) -> bool {
797 let q = query.to_lowercase();
798 let pkg = &result.entity;
799 let empty = String::new();
800 pkg.name.to_lowercase().contains(&q)
801 || pkg
802 .description
803 .as_ref()
804 .unwrap_or(&empty)
805 .to_lowercase()
806 .contains(&q)
807 || pkg
808 .category
809 .as_ref()
810 .unwrap_or(&empty)
811 .to_lowercase()
812 .contains(&q)
813 || pkg
814 .docstring
815 .as_ref()
816 .unwrap_or(&empty)
817 .to_lowercase()
818 .contains(&q)
819}
820
821fn build_index(app_dir: &AppDir, source_dir: Option<&std::path::Path>) -> Result<HubIndex, String> {
838 let empty = || HubIndex {
839 schema_version: "hub_index/v0".into(),
840 updated_at: super::manifest::now_iso8601(),
841 packages: Vec::new(),
842 };
843
844 let pkg_dir = match source_dir {
845 Some(d) => d.to_path_buf(),
846 None => app_dir.packages_dir(),
847 };
848
849 let use_local_state = source_dir.is_none();
850 let card_counts = if use_local_state {
851 local_card_counts(app_dir)
852 } else {
853 HashMap::new()
854 };
855 let manifest = if use_local_state {
862 manifest::load_manifest(app_dir)?
863 } else {
864 manifest::Manifest::default()
865 };
866
867 let mut entries = Vec::new();
868
869 let dir_entries = match std::fs::read_dir(&pkg_dir) {
873 Ok(e) => e,
874 Err(_) => return Ok(empty()),
875 };
876
877 for entry in dir_entries.flatten() {
878 if !entry.path().is_dir() {
879 continue;
880 }
881 let dir_name = match entry.file_name().to_str() {
882 Some(n) if !n.starts_with('.') && !n.starts_with('_') => n.to_string(),
883 _ => continue,
884 };
885
886 let init_lua = entry.path().join("init.lua");
887 if !init_lua.exists() {
888 continue;
889 }
890
891 let Some(entity) = PkgEntity::parse_from_init_lua(&init_lua) else {
898 continue;
899 };
900
901 let source = manifest
905 .packages
906 .get(&dir_name)
907 .map(|e| e.source.clone())
908 .unwrap_or_default();
909
910 entries.push(IndexEntry {
911 entity,
912 source,
913 card_count: card_counts.get(&dir_name).copied().unwrap_or(0),
914 best_card: None,
915 });
916 }
917
918 entries.sort_by(|a, b| a.entity.name.cmp(&b.entity.name));
919
920 Ok(HubIndex {
921 schema_version: "hub_index/v0".into(),
922 updated_at: super::manifest::now_iso8601(),
923 packages: entries,
924 })
925}
926
927impl AppService {
930 pub fn hub_reindex(
939 &self,
940 output_path: Option<&str>,
941 source_dir: Option<&str>,
942 ) -> Result<String, String> {
943 let src = source_dir.map(std::path::Path::new);
944 if let Some(d) = src {
945 if !d.is_dir() {
946 return Err(format!("source_dir '{}' is not a directory", d.display()));
947 }
948 }
949 let app_dir = self.log_config.app_dir();
950 let index = build_index(&app_dir, src)?;
951
952 let written_path = if let Some(path) = output_path {
953 let json = serde_json::to_string_pretty(&index)
954 .map_err(|e| format!("Failed to serialize index: {e}"))?;
955 std::fs::write(path, &json)
956 .map_err(|e| format!("Failed to write index to {path}: {e}"))?;
957 Some(path.to_string())
958 } else {
959 None
960 };
961
962 let response = serde_json::json!({
963 "package_count": index.packages.len(),
964 "updated_at": index.updated_at,
965 "output_path": written_path,
966 "source_dir": source_dir,
967 });
968 Ok(response.to_string())
969 }
970
971 pub fn hub_info(&self, pkg: &str) -> Result<String, String> {
976 use algocline_engine::card;
977
978 if pkg.contains("..") || pkg.contains('/') || pkg.contains('\\') {
980 return Err(format!("Invalid package name: '{pkg}'"));
981 }
982
983 let app_dir = self.log_config.app_dir();
985 let installed = installed_packages(&app_dir)?;
986 let is_installed = installed.contains_key(pkg);
987
988 let (version, description, category, source) = {
994 let (remote, _) = fetch_remote_indices(&app_dir)?;
995 if let Some(entry) = remote.packages.iter().find(|e| e.entity.name == pkg) {
996 (
997 entry.entity.version.clone().unwrap_or_default(),
998 entry.entity.description.clone().unwrap_or_default(),
999 entry.entity.category.clone().unwrap_or_default(),
1000 entry.source.clone(),
1001 )
1002 } else if is_installed {
1003 let init_lua = app_dir.packages_dir().join(pkg).join("init.lua");
1009 let entity = PkgEntity::parse_from_init_lua(&init_lua);
1010 let manifest_source = manifest::load_manifest(&app_dir)?
1011 .packages
1012 .get(pkg)
1013 .map(|e| e.source.clone())
1014 .unwrap_or_default();
1015 match entity {
1016 Some(e) => (
1017 e.version.unwrap_or_default(),
1018 e.description.unwrap_or_default(),
1019 e.category.unwrap_or_default(),
1020 manifest_source,
1021 ),
1022 None => (
1023 installed.get(pkg).cloned().flatten().unwrap_or_default(),
1024 String::new(),
1025 String::new(),
1026 manifest_source,
1027 ),
1028 }
1029 } else {
1030 return Err(format!(
1031 "Package '{pkg}' not found in remote indices or locally installed packages"
1032 ));
1033 }
1034 };
1035
1036 let card_rows = self.card_store.list(Some(pkg)).unwrap_or_default();
1038 let cards_json = card::summaries_to_json(&card_rows);
1039
1040 let aliases_json = match self.card_store.alias_list(Some(pkg)) {
1042 Ok(rows) => card::aliases_to_json(&rows),
1043 Err(_) => serde_json::json!([]),
1044 };
1045
1046 let card_count = card_rows.len();
1048 let best_pass_rate = card_rows
1049 .iter()
1050 .filter_map(|c| c.pass_rate)
1051 .fold(f64::NEG_INFINITY, f64::max);
1052 let best_pass_rate = if best_pass_rate.is_finite() {
1053 Some(best_pass_rate)
1054 } else {
1055 None
1056 };
1057
1058 let eval_count = count_evals_for_pkg(&app_dir, pkg);
1060
1061 let response = serde_json::json!({
1062 "pkg": {
1063 "name": pkg,
1064 "version": version,
1065 "description": description,
1066 "category": category,
1067 "source": source,
1068 "installed": is_installed,
1069 },
1070 "cards": cards_json,
1071 "aliases": aliases_json,
1072 "stats": {
1073 "card_count": card_count,
1074 "eval_count": eval_count,
1075 "best_pass_rate": best_pass_rate,
1076 },
1077 });
1078 Ok(response.to_string())
1079 }
1080
1081 pub(crate) fn hub_search(
1113 &self,
1114 query: Option<&str>,
1115 category: Option<&str>,
1116 installed_only: Option<bool>,
1117 opts: ListOpts,
1118 ) -> Result<String, String> {
1119 let app_dir = self.log_config.app_dir();
1120 let (remote, warnings) = fetch_remote_indices(&app_dir)?;
1121 let mut results = merge(&app_dir, &remote)?;
1122
1123 let query_lower = query.filter(|q| !q.is_empty()).map(|q| q.to_lowercase());
1126 if let Some(ref ql) = query_lower {
1127 results.retain(|r| matches_query(r, ql));
1128 }
1129
1130 if let Some(ref ql) = query_lower {
1134 for r in &mut results {
1135 let empty = String::new();
1136 let pkg = &r.entity;
1137 let other_hit = pkg.name.to_lowercase().contains(ql)
1138 || pkg
1139 .description
1140 .as_ref()
1141 .unwrap_or(&empty)
1142 .to_lowercase()
1143 .contains(ql)
1144 || pkg
1145 .category
1146 .as_ref()
1147 .unwrap_or(&empty)
1148 .to_lowercase()
1149 .contains(ql);
1150 let doc_hit = pkg
1151 .docstring
1152 .as_ref()
1153 .unwrap_or(&empty)
1154 .to_lowercase()
1155 .contains(ql);
1156 r.docstring_matched = if !other_hit && doc_hit {
1157 Some(true)
1158 } else {
1159 None
1160 };
1161 }
1162 }
1163
1164 let mut filter_map: std::collections::HashMap<String, serde_json::Value> =
1168 opts.filter.unwrap_or_default();
1169 if let Some(cat) = category {
1170 filter_map
1171 .entry("category".to_string())
1172 .or_insert_with(|| serde_json::Value::String(cat.to_string()));
1173 }
1174 if let Some(only) = installed_only {
1175 if only {
1179 filter_map
1180 .entry("installed".to_string())
1181 .or_insert(serde_json::Value::Bool(true));
1182 }
1183 }
1184
1185 let sort_str = opts.sort.as_deref().unwrap_or("-installed,name");
1188 let sort_keys = parse_sort(sort_str)?;
1189
1190 let fields = resolve_fields(
1193 opts.verbose.as_deref(),
1194 opts.fields.as_deref(),
1195 HUB_SEARCH_SUMMARY,
1196 HUB_SEARCH_FULL,
1197 )?;
1198 let include_docstring = fields.iter().any(|f| f == "docstring");
1199
1200 let mut items: Vec<serde_json::Value> = results
1203 .iter()
1204 .map(|r| r.to_value_with_optional_docstring(include_docstring))
1205 .collect();
1206
1207 if !filter_map.is_empty() {
1210 items.retain(|v| matches_filter(v, &filter_map));
1211 }
1212
1213 apply_sort_by_value(&mut items, &sort_keys);
1215
1216 let total = items.len();
1220 let limit = opts.limit.unwrap_or(50);
1221 if limit > 0 {
1222 items.truncate(limit);
1223 }
1224
1225 let projected: Vec<serde_json::Value> = items
1228 .into_iter()
1229 .map(|v| project_fields(v, &fields))
1230 .collect();
1231
1232 let sources = discover_index_urls(&app_dir)?;
1234
1235 let mut json = serde_json::json!({
1236 "results": projected,
1237 "total": total,
1238 "sources": sources,
1239 });
1240 if !warnings.is_empty() {
1241 json["warnings"] = serde_json::json!(warnings);
1242 }
1243 Ok(json.to_string())
1244 }
1245}
1246
1247#[cfg(test)]
1248mod tests {
1249 use super::*;
1250
1251 #[test]
1252 fn repo_to_index_url_github() {
1253 assert_eq!(
1254 repo_to_index_url("https://github.com/ynishi/algocline-bundled-packages"),
1255 Some(
1256 "https://raw.githubusercontent.com/ynishi/algocline-bundled-packages/main/hub_index.json"
1257 .to_string()
1258 )
1259 );
1260 }
1261
1262 #[test]
1263 fn repo_to_index_url_github_trailing_slash() {
1264 assert_eq!(
1265 repo_to_index_url("https://github.com/user/repo/"),
1266 Some("https://raw.githubusercontent.com/user/repo/main/hub_index.json".to_string())
1267 );
1268 }
1269
1270 #[test]
1271 fn repo_to_index_url_github_dot_git() {
1272 assert_eq!(
1273 repo_to_index_url("https://github.com/user/repo.git"),
1274 Some("https://raw.githubusercontent.com/user/repo/main/hub_index.json".to_string())
1275 );
1276 }
1277
1278 #[test]
1279 fn repo_to_index_url_direct_json() {
1280 assert_eq!(
1281 repo_to_index_url("https://example.com/my_index.json"),
1282 Some("https://example.com/my_index.json".to_string())
1283 );
1284 }
1285
1286 #[test]
1287 fn repo_to_index_url_unknown_host_no_json() {
1288 assert_eq!(repo_to_index_url("https://example.com/some-repo"), None);
1289 }
1290
1291 #[test]
1292 fn repo_to_index_url_local_path() {
1293 assert_eq!(repo_to_index_url("/home/user/my-pkg"), None);
1294 }
1295
1296 #[test]
1297 fn cache_key_stable() {
1298 let k1 = cache_key("https://example.com/index.json");
1299 let k2 = cache_key("https://example.com/index.json");
1300 assert_eq!(k1, k2);
1301 assert_eq!(k1.len(), 16); }
1303
1304 #[test]
1305 fn cache_key_different_urls() {
1306 let k1 = cache_key("https://a.com/index.json");
1307 let k2 = cache_key("https://b.com/index.json");
1308 assert_ne!(k1, k2);
1309 }
1310
1311 #[test]
1317 fn merge_dedup_uses_hashset() {
1318 let tmp = tempfile::tempdir().unwrap();
1321 let app_dir = AppDir::new(tmp.path().to_path_buf());
1322 let remote = HubIndex {
1323 schema_version: "hub_index/v0".into(),
1324 updated_at: String::new(),
1325 packages: vec![IndexEntry {
1326 entity: PkgEntity {
1327 name: "remote_only".into(),
1328 version: Some("1.0".into()),
1329 description: Some("from remote".into()),
1330 category: Some("test".into()),
1331 docstring: None,
1332 },
1333 source: PackageSource::Unknown,
1334 card_count: 0,
1335 best_card: None,
1336 }],
1337 };
1338
1339 let results = merge(&app_dir, &remote).expect("merge over empty app_dir should succeed");
1340 assert!(results.iter().any(|r| r.entity.name == "remote_only"));
1342 }
1343
1344 #[test]
1345 fn matches_query_searches_docstring() {
1346 let result = SearchResult {
1347 entity: PkgEntity {
1348 name: "cascade".into(),
1349 version: Some("0.1.0".into()),
1350 description: Some("Multi-level routing".into()),
1351 category: Some("meta".into()),
1352 docstring: Some("Based on FrugalGPT. Uses Thompson Sampling.".into()),
1353 },
1354 source: PackageSource::Unknown,
1355 installed: true,
1356 card_count: 0,
1357 best_card: None,
1358 docstring_matched: None,
1359 };
1360
1361 assert!(matches_query(&result, "thompson"), "docstring match");
1362 assert!(matches_query(&result, "FrugalGPT"), "docstring match case");
1363 assert!(matches_query(&result, "routing"), "description match");
1364 assert!(!matches_query(&result, "bayesian"), "no match");
1365 }
1366
1367 fn sample_search_result() -> SearchResult {
1376 SearchResult {
1377 entity: PkgEntity {
1378 name: "cascade".into(),
1379 version: Some("0.1.0".into()),
1380 description: Some("Multi-level routing".into()),
1381 category: Some("reasoning".into()),
1382 docstring: Some("Based on FrugalGPT. Uses Thompson Sampling.".into()),
1383 },
1384 source: PackageSource::Git {
1385 url: "https://example.com/cascade".into(),
1386 rev: None,
1387 },
1388 installed: true,
1389 card_count: 3,
1390 best_card: None,
1391 docstring_matched: None,
1392 }
1393 }
1394
1395 #[test]
1396 fn to_value_default_omits_docstring() {
1397 let r = sample_search_result();
1398 let v = r.to_value_with_optional_docstring(false);
1399 let obj = v.as_object().expect("object");
1400 assert!(
1401 !obj.contains_key("docstring"),
1402 "default summary must not leak docstring"
1403 );
1404 assert_eq!(obj.get("name").and_then(|x| x.as_str()), Some("cascade"));
1405 assert!(
1408 !obj.contains_key("docstring_matched"),
1409 "docstring_matched=None must be omitted"
1410 );
1411 }
1412
1413 #[test]
1414 fn to_value_include_reattaches_docstring() {
1415 let r = sample_search_result();
1416 let v = r.to_value_with_optional_docstring(true);
1417 let obj = v.as_object().expect("object");
1418 assert_eq!(
1419 obj.get("docstring").and_then(|x| x.as_str()),
1420 Some("Based on FrugalGPT. Uses Thompson Sampling.")
1421 );
1422 }
1423
1424 #[test]
1425 fn to_value_serializes_docstring_matched_when_set() {
1426 let mut r = sample_search_result();
1427 r.docstring_matched = Some(true);
1428 let v = r.to_value_with_optional_docstring(false);
1429 let obj = v.as_object().expect("object");
1430 assert_eq!(
1431 obj.get("docstring_matched").and_then(|x| x.as_bool()),
1432 Some(true)
1433 );
1434 }
1435
1436 #[test]
1446 fn hub_search_default_summary_excludes_docstring() {
1447 let r = sample_search_result();
1448 let fields = resolve_fields(None, None, HUB_SEARCH_SUMMARY, HUB_SEARCH_FULL).unwrap();
1449 let include_docstring = fields.iter().any(|f| f == "docstring");
1450 let v = project_fields(
1451 r.to_value_with_optional_docstring(include_docstring),
1452 &fields,
1453 );
1454 let obj = v.as_object().expect("object");
1455 assert!(
1456 !obj.contains_key("docstring"),
1457 "summary preset must omit docstring"
1458 );
1459 for key in ["name", "version", "description", "category", "installed"] {
1461 assert!(obj.contains_key(key), "summary preset key {key} missing");
1462 }
1463 }
1464
1465 #[test]
1466 fn hub_search_verbose_full_includes_docstring() {
1467 let r = sample_search_result();
1468 let fields =
1469 resolve_fields(Some("full"), None, HUB_SEARCH_SUMMARY, HUB_SEARCH_FULL).unwrap();
1470 let include_docstring = fields.iter().any(|f| f == "docstring");
1471 let v = project_fields(
1472 r.to_value_with_optional_docstring(include_docstring),
1473 &fields,
1474 );
1475 let obj = v.as_object().expect("object");
1476 assert_eq!(
1477 obj.get("docstring").and_then(|x| x.as_str()),
1478 Some("Based on FrugalGPT. Uses Thompson Sampling.")
1479 );
1480 for key in ["source", "card_count"] {
1482 assert!(obj.contains_key(key), "full preset key {key} missing");
1483 }
1484 }
1485
1486 #[test]
1487 fn hub_search_fields_beats_verbose() {
1488 let r = sample_search_result();
1489 let explicit = vec!["name".to_string(), "docstring".to_string()];
1490 let fields = resolve_fields(
1493 Some("summary"),
1494 Some(&explicit),
1495 HUB_SEARCH_SUMMARY,
1496 HUB_SEARCH_FULL,
1497 )
1498 .unwrap();
1499 let include_docstring = fields.iter().any(|f| f == "docstring");
1500 let v = project_fields(
1501 r.to_value_with_optional_docstring(include_docstring),
1502 &fields,
1503 );
1504 let obj = v.as_object().expect("object");
1505 assert_eq!(obj.len(), 2, "only the two requested fields");
1506 assert!(obj.contains_key("name"));
1507 assert!(obj.contains_key("docstring"));
1508 }
1509
1510 #[test]
1511 fn hub_search_fields_unknown_key_silently_skipped() {
1512 let r = sample_search_result();
1513 let explicit = vec!["name".to_string(), "bogus".to_string()];
1514 let fields =
1515 resolve_fields(None, Some(&explicit), HUB_SEARCH_SUMMARY, HUB_SEARCH_FULL).unwrap();
1516 let v = project_fields(r.to_value_with_optional_docstring(false), &fields);
1517 let obj = v.as_object().expect("object");
1518 assert_eq!(obj.len(), 1, "bogus must not appear");
1519 assert!(obj.contains_key("name"));
1520 }
1521
1522 #[test]
1523 fn hub_search_invalid_verbose_errors() {
1524 let err =
1525 resolve_fields(Some("fat"), None, HUB_SEARCH_SUMMARY, HUB_SEARCH_FULL).unwrap_err();
1526 assert!(
1527 err.contains("fat"),
1528 "error must mention the offending value"
1529 );
1530 }
1531
1532 fn classify(r: &SearchResult, query: &str) -> Option<bool> {
1541 let ql = query.to_lowercase();
1542 if query.is_empty() {
1543 return None;
1544 }
1545 let empty = String::new();
1546 let pkg = &r.entity;
1547 let other_hit = pkg.name.to_lowercase().contains(&ql)
1548 || pkg
1549 .description
1550 .as_ref()
1551 .unwrap_or(&empty)
1552 .to_lowercase()
1553 .contains(&ql)
1554 || pkg
1555 .category
1556 .as_ref()
1557 .unwrap_or(&empty)
1558 .to_lowercase()
1559 .contains(&ql);
1560 let doc_hit = pkg
1561 .docstring
1562 .as_ref()
1563 .unwrap_or(&empty)
1564 .to_lowercase()
1565 .contains(&ql);
1566 if !other_hit && doc_hit {
1567 Some(true)
1568 } else {
1569 None
1570 }
1571 }
1572
1573 #[test]
1574 fn docstring_matched_true_when_only_docstring_hits() {
1575 let r = sample_search_result();
1576 assert_eq!(classify(&r, "thompson"), Some(true));
1578 }
1579
1580 #[test]
1581 fn docstring_matched_none_when_name_also_hits() {
1582 let r = sample_search_result();
1583 assert_eq!(classify(&r, "cascade"), None);
1585 }
1586
1587 #[test]
1588 fn docstring_matched_none_when_description_hits() {
1589 let r = sample_search_result();
1590 assert_eq!(classify(&r, "routing"), None);
1592 }
1593
1594 #[test]
1595 fn docstring_matched_none_when_query_empty() {
1596 let r = sample_search_result();
1597 assert_eq!(classify(&r, ""), None);
1598 }
1599
1600 fn build_filter_map(
1608 category: Option<&str>,
1609 installed_only: Option<bool>,
1610 explicit: Option<HashMap<String, serde_json::Value>>,
1611 ) -> HashMap<String, serde_json::Value> {
1612 let mut filter_map = explicit.unwrap_or_default();
1613 if let Some(cat) = category {
1614 filter_map
1615 .entry("category".to_string())
1616 .or_insert_with(|| serde_json::Value::String(cat.to_string()));
1617 }
1618 if let Some(only) = installed_only {
1619 if only {
1620 filter_map
1621 .entry("installed".to_string())
1622 .or_insert(serde_json::Value::Bool(true));
1623 }
1624 }
1625 filter_map
1626 }
1627
1628 #[test]
1629 fn filter_by_category_via_legacy_param() {
1630 let m = build_filter_map(Some("reasoning"), None, None);
1631 assert_eq!(
1632 m.get("category"),
1633 Some(&serde_json::Value::String("reasoning".to_string()))
1634 );
1635 }
1636
1637 #[test]
1638 fn filter_by_installed_only_via_legacy_param() {
1639 let m = build_filter_map(None, Some(true), None);
1640 assert_eq!(m.get("installed"), Some(&serde_json::Value::Bool(true)));
1641 }
1642
1643 #[test]
1644 fn filter_installed_only_false_is_noop() {
1645 let m = build_filter_map(None, Some(false), None);
1646 assert!(
1647 !m.contains_key("installed"),
1648 "installed_only=false should not fold in"
1649 );
1650 }
1651
1652 #[test]
1653 fn filter_beats_legacy_param_on_conflict() {
1654 let mut explicit = HashMap::new();
1657 explicit.insert(
1658 "category".to_string(),
1659 serde_json::Value::String("meta".to_string()),
1660 );
1661 let m = build_filter_map(Some("reasoning"), None, Some(explicit));
1662 assert_eq!(
1663 m.get("category"),
1664 Some(&serde_json::Value::String("meta".to_string()))
1665 );
1666 }
1667
1668 #[test]
1669 fn filter_merges_legacy_when_no_conflict() {
1670 let mut explicit = HashMap::new();
1673 explicit.insert("installed".to_string(), serde_json::Value::Bool(true));
1674 let m = build_filter_map(Some("reasoning"), None, Some(explicit));
1675 assert_eq!(
1676 m.get("category"),
1677 Some(&serde_json::Value::String("reasoning".to_string()))
1678 );
1679 assert_eq!(m.get("installed"), Some(&serde_json::Value::Bool(true)));
1680 }
1681
1682 #[test]
1685 fn default_sort_is_minus_installed_name() {
1686 let keys = parse_sort("-installed,name").unwrap();
1687 assert_eq!(keys.len(), 2);
1688 assert_eq!(keys[0].key, "installed");
1689 assert!(keys[0].desc, "installed must sort desc (true first)");
1690 assert_eq!(keys[1].key, "name");
1691 assert!(!keys[1].desc);
1692
1693 let mut items = vec![
1695 serde_json::json!({"installed": false, "name": "zeta"}),
1696 serde_json::json!({"installed": true, "name": "mu"}),
1697 serde_json::json!({"installed": false, "name": "alpha"}),
1698 serde_json::json!({"installed": true, "name": "beta"}),
1699 ];
1700 apply_sort_by_value(&mut items, &keys);
1701 let names: Vec<&str> = items
1702 .iter()
1703 .map(|v| v.get("name").and_then(|x| x.as_str()).unwrap_or(""))
1704 .collect();
1705 assert_eq!(names, vec!["beta", "mu", "alpha", "zeta"]);
1706 }
1707}