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;
91use super::HubRegistriesError;
92
93const CACHE_TTL_SECS: u64 = 3600;
97
98const HTTP_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(30);
100
101#[derive(Debug, Clone, Serialize, Deserialize)]
105pub(crate) struct HubIndex {
106 pub schema_version: String,
107 #[serde(default)]
108 pub updated_at: String,
109 #[serde(default)]
110 pub packages: Vec<IndexEntry>,
111}
112
113#[derive(Debug, Clone, Serialize, Deserialize)]
121pub(crate) struct IndexEntry {
122 #[serde(flatten)]
123 pub entity: PkgEntity,
124 #[serde(default)]
128 pub source: PackageSource,
129 #[serde(default)]
130 pub card_count: usize,
131 #[serde(default)]
132 pub best_card: Option<BestCard>,
133}
134
135#[derive(Debug, Clone, Serialize, Deserialize)]
137pub(crate) struct BestCard {
138 pub card_id: String,
139 #[serde(default)]
140 pub model: String,
141 #[serde(default)]
142 pub pass_rate: f64,
143 #[serde(default)]
144 pub scenario: String,
145}
146
147#[derive(Debug, Clone, Serialize)]
168struct SearchResult {
169 #[serde(flatten, serialize_with = "serialize_entity_without_docstring")]
170 entity: PkgEntity,
171 source: PackageSource,
173 installed: bool,
174 card_count: usize,
175 best_card: Option<BestCard>,
176 #[serde(skip_serializing_if = "Option::is_none")]
177 docstring_matched: Option<bool>,
178}
179
180fn serialize_entity_without_docstring<S>(entity: &PkgEntity, ser: S) -> Result<S::Ok, S::Error>
185where
186 S: serde::Serializer,
187{
188 use serde::ser::SerializeMap;
189 let mut map = ser.serialize_map(Some(4))?;
190 map.serialize_entry("name", &entity.name)?;
191 map.serialize_entry("version", &entity.version)?;
192 map.serialize_entry("description", &entity.description)?;
193 map.serialize_entry("category", &entity.category)?;
194 map.end()
195}
196
197impl SearchResult {
198 fn to_value_with_optional_docstring(&self, include_docstring: bool) -> serde_json::Value {
211 let mut v = serde_json::to_value(self).unwrap_or(serde_json::Value::Null);
212 if include_docstring {
213 if let serde_json::Value::Object(ref mut map) = v {
214 let doc = self.entity.docstring.clone().unwrap_or_default();
215 map.insert("docstring".to_string(), serde_json::Value::String(doc));
216 }
217 }
218 v
219 }
220}
221
222#[derive(Debug, Clone, Serialize, Deserialize)]
231pub(crate) struct RegistryEntry {
232 pub source: String,
234 pub origin: String,
236 pub added_at: String,
238}
239
240#[derive(Debug, Clone, Serialize, Deserialize, Default)]
242pub(crate) struct HubRegistries {
243 pub registries: Vec<RegistryEntry>,
244}
245
246fn registries_path(app_dir: &AppDir) -> PathBuf {
247 app_dir.hub_registries_json()
248}
249
250fn load_registries(app_dir: &AppDir) -> Result<HubRegistries, HubRegistriesError> {
258 let path = registries_path(app_dir);
259 if !path.exists() {
260 return Ok(HubRegistries::default());
261 }
262 let content = std::fs::read_to_string(&path).map_err(|e| {
263 HubRegistriesError::Parse(format!(
264 "failed to read hub_registries.json at {}: {e}",
265 path.display()
266 ))
267 })?;
268 serde_json::from_str::<HubRegistries>(&content).map_err(|e| {
269 HubRegistriesError::Parse(format!(
270 "failed to parse hub_registries.json at {}: {e}",
271 path.display()
272 ))
273 })
274}
275
276pub(crate) fn register_source(app_dir: &AppDir, source: &str, origin: &str) -> Result<(), String> {
290 let normalized = source.trim_end_matches('/').to_string();
291 if normalized.is_empty() {
292 return Ok(());
293 }
294 if normalized.starts_with('/') || normalized.starts_with('.') {
296 return Ok(());
297 }
298
299 let path = registries_path(app_dir);
300 if let Some(parent) = path.parent() {
301 std::fs::create_dir_all(parent).map_err(|e| {
302 format!(
303 "failed to create hub registries dir {}: {e}",
304 parent.display()
305 )
306 })?;
307 }
308
309 let mut reg = load_registries(app_dir).map_err(|e| format!("cannot register source: {e}"))?;
313
314 if reg
316 .registries
317 .iter()
318 .any(|e| e.source.trim_end_matches('/') == normalized)
319 {
320 return Ok(());
321 }
322
323 reg.registries.push(RegistryEntry {
324 source: normalized,
325 origin: origin.to_string(),
326 added_at: manifest::now_iso8601(),
327 });
328
329 let json = serde_json::to_string_pretty(®)
331 .map_err(|e| format!("failed to serialize hub registries: {e}"))?;
332 let tmp_path = path.with_extension("json.tmp");
333 std::fs::write(&tmp_path, &json).map_err(|e| {
334 format!(
335 "failed to write hub registries tmp {}: {e}",
336 tmp_path.display()
337 )
338 })?;
339 std::fs::rename(&tmp_path, &path).map_err(|e| {
340 let _ = std::fs::remove_file(&tmp_path);
342 format!(
343 "failed to atomically rename hub registries onto {}: {e}",
344 path.display()
345 )
346 })
347}
348
349fn collection_url_from_config(app_dir: &AppDir) -> Result<Option<String>, String> {
366 let path = app_dir.config_toml();
367 let content = match std::fs::read_to_string(&path) {
368 Ok(c) => c,
369 Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
370 Err(_) => return Ok(None), };
372 let doc: toml_edit::DocumentMut = content
373 .parse()
374 .map_err(|e| format!("config.toml parse: {e}"))?;
375 let url = match doc
376 .get("hub")
377 .and_then(|h| h.get("collection_url"))
378 .and_then(|v| v.as_str())
379 {
380 Some(s) => s.trim().to_string(),
381 None => return Ok(None),
382 };
383 if url.is_empty() {
384 Ok(None)
385 } else {
386 Ok(Some(url))
387 }
388}
389
390fn repo_to_index_url(repo_url: &str) -> Option<String> {
405 let trimmed = repo_url.trim_end_matches('/').trim_end_matches(".git");
406 if let Some(path) = trimmed.strip_prefix("https://github.com/") {
407 let parts: Vec<&str> = path.splitn(3, '/').collect();
409 if parts.len() >= 2 {
410 return Some(format!(
411 "https://raw.githubusercontent.com/{}/{}/main/hub_index.json",
412 parts[0], parts[1]
413 ));
414 }
415 }
416 if trimmed.ends_with(".json") {
418 Some(trimmed.to_string())
419 } else {
420 None
421 }
422}
423
424fn discover_index_urls(
435 app_dir: &AppDir,
436 warnings: &mut Vec<String>,
437) -> Result<Vec<String>, String> {
438 let mut index_urls: Vec<String> = Vec::new();
439
440 match collection_url_from_config(app_dir) {
445 Ok(Some(url)) => index_urls.push(url),
446 Ok(None) => {}
447 Err(e) => warnings.push(format!("config.toml hub.collection_url: {e}")),
448 }
449
450 let mut repo_urls: HashSet<String> = HashSet::new();
451
452 let reg = load_registries(app_dir).map_err(|e| e.to_string())?;
458 for entry in ®.registries {
459 let normalized = entry.source.trim_end_matches('/').to_string();
460 if !normalized.is_empty() {
461 repo_urls.insert(normalized);
462 }
463 }
464
465 let m = manifest::load_manifest(app_dir)?;
469 for entry in m.packages.values() {
470 if let Some(url) = entry.source.git_url() {
471 let normalized = url.trim_end_matches('/').to_string();
472 if !normalized.is_empty() {
473 repo_urls.insert(normalized);
474 }
475 }
476 }
477
478 for url in AUTO_INSTALL_SOURCES {
480 repo_urls.insert(url.to_string());
481 }
482
483 let existing: HashSet<String> = index_urls.iter().cloned().collect();
485 let mut derived: Vec<String> = repo_urls
486 .iter()
487 .filter_map(|url| repo_to_index_url(url))
488 .filter(|url| !existing.contains(url))
489 .collect();
490 derived.sort();
491 derived.dedup();
492 index_urls.extend(derived);
493
494 Ok(index_urls)
495}
496
497fn cache_dir(app_dir: &AppDir) -> PathBuf {
505 app_dir.hub_cache_dir()
506}
507
508fn cache_key(url: &str) -> String {
509 let mut h: u64 = 0xcbf2_9ce4_8422_2325; for b in url.as_bytes() {
513 h ^= *b as u64;
514 h = h.wrapping_mul(0x0100_0000_01b3); }
516 format!("{h:016x}")
517}
518
519enum CacheLookup {
526 NotPresent,
528 Stale(HubIndex),
530 Fresh(HubIndex),
532 Corrupt(String),
534}
535
536fn load_cached_full(app_dir: &AppDir, url: &str) -> CacheLookup {
541 let dir = cache_dir(app_dir);
542 let path = dir.join(format!("{}.json", cache_key(url)));
543 if !path.exists() {
544 return CacheLookup::NotPresent;
545 }
546 let metadata = match std::fs::metadata(&path) {
547 Ok(m) => m,
548 Err(_) => return CacheLookup::NotPresent,
549 };
550 let age = match metadata.modified().ok().and_then(|t| t.elapsed().ok()) {
551 Some(a) => a,
552 None => return CacheLookup::NotPresent,
553 };
554 let content = match std::fs::read_to_string(&path) {
555 Ok(c) => c,
556 Err(e) => return CacheLookup::Corrupt(format!("hub cache read {}: {e}", path.display())),
557 };
558 match serde_json::from_str::<HubIndex>(&content) {
559 Ok(index) => {
560 if age.as_secs() > CACHE_TTL_SECS {
561 CacheLookup::Stale(index)
562 } else {
563 CacheLookup::Fresh(index)
564 }
565 }
566 Err(e) => CacheLookup::Corrupt(format!("hub cache parse {}: {e}", path.display())),
567 }
568}
569
570fn load_cached(app_dir: &AppDir, url: &str) -> Result<Option<HubIndex>, String> {
578 match load_cached_full(app_dir, url) {
579 CacheLookup::Fresh(index) => Ok(Some(index)),
580 CacheLookup::NotPresent | CacheLookup::Stale(_) => Ok(None),
581 CacheLookup::Corrupt(msg) => Err(msg),
582 }
583}
584
585fn save_cached(app_dir: &AppDir, url: &str, index: &HubIndex) -> Result<(), String> {
592 let dir = cache_dir(app_dir);
593 std::fs::create_dir_all(&dir)
594 .map_err(|e| format!("failed to create hub cache dir {}: {e}", dir.display()))?;
595 let path = dir.join(format!("{}.json", cache_key(url)));
596 let json = serde_json::to_string_pretty(index)
597 .map_err(|e| format!("failed to serialize hub cache: {e}"))?;
598 std::fs::write(&path, json)
599 .map_err(|e| format!("failed to write hub cache {}: {e}", path.display()))
600}
601
602fn fetch_one(app_dir: &AppDir, url: &str) -> Result<(HubIndex, Option<String>), String> {
614 match load_cached(app_dir, url) {
616 Ok(Some(cached)) => return Ok((cached, None)),
617 Ok(None) => {} Err(e) => {
619 let warn = format!("hub cache corrupted for {url}: {e}; falling back to network");
623 return fetch_one_from_network(app_dir, url)
625 .map(|(idx, save_warn)| {
626 let combined = Some(match save_warn {
628 Some(sw) => format!("{warn}; {sw}"),
629 None => warn.clone(),
630 });
631 (idx, combined)
632 })
633 .map_err(|fetch_err| format!("{warn}; network fetch also failed: {fetch_err}"));
634 }
635 }
636
637 fetch_one_from_network(app_dir, url)
638}
639
640fn fetch_one_from_network(
644 app_dir: &AppDir,
645 url: &str,
646) -> Result<(HubIndex, Option<String>), String> {
647 let agent = ureq::Agent::new_with_config(
648 ureq::config::Config::builder()
649 .timeout_global(Some(HTTP_TIMEOUT))
650 .build(),
651 );
652 let body: String = agent
653 .get(url)
654 .call()
655 .map_err(|e| format!("Failed to fetch {url}: {e}"))?
656 .body_mut()
657 .read_to_string()
658 .map_err(|e| format!("Failed to read response from {url}: {e}"))?;
659
660 let index: HubIndex = serde_json::from_str(&body)
661 .map_err(|e| format!("Failed to parse index from {url}: {e}"))?;
662
663 let cache_warning = save_cached(app_dir, url, &index)
664 .err()
665 .map(|e| format!("hub cache write for {url}: {e}"));
666 Ok((index, cache_warning))
667}
668
669fn fetch_remote_indices(app_dir: &AppDir) -> Result<(HubIndex, Vec<String>), String> {
672 let mut warnings: Vec<String> = Vec::new();
673 let urls = discover_index_urls(app_dir, &mut warnings)?;
674 let mut all_packages: Vec<IndexEntry> = Vec::new();
675 let mut seen_names: HashSet<String> = HashSet::new();
676
677 for url in &urls {
678 match fetch_one(app_dir, url) {
679 Ok((index, cache_warning)) => {
680 for entry in index.packages {
681 if seen_names.insert(entry.entity.name.clone()) {
682 all_packages.push(entry);
683 }
684 }
686 if let Some(w) = cache_warning {
687 warnings.push(w);
688 }
689 }
690 Err(e) => {
691 warnings.push(e);
692 }
693 }
694 }
695
696 if all_packages.is_empty() && !warnings.is_empty() {
697 warnings.insert(
698 0,
699 "all remote indices unavailable, showing local packages only".to_string(),
700 );
701 }
702
703 let merged = HubIndex {
704 schema_version: "hub_index/v0".into(),
705 updated_at: String::new(),
706 packages: all_packages,
707 };
708 Ok((merged, warnings))
709}
710
711fn installed_packages(app_dir: &AppDir) -> Result<HashMap<String, Option<String>>, String> {
716 let mut map = HashMap::new();
717
718 let m = manifest::load_manifest(app_dir)?;
720 for (name, entry) in &m.packages {
721 map.insert(name.clone(), entry.version.clone());
722 }
723
724 let pkg_dir = app_dir.packages_dir();
726 if let Ok(entries) = std::fs::read_dir(&pkg_dir) {
727 for entry in entries.flatten() {
728 if entry.path().is_dir() {
729 if let Some(name) = entry.file_name().to_str() {
730 map.entry(name.to_string()).or_insert(None);
731 }
732 }
733 }
734 }
735
736 Ok(map)
737}
738
739fn local_card_counts(app_dir: &AppDir) -> HashMap<String, usize> {
741 let mut map = HashMap::new();
742 let cards_dir = app_dir.cards_dir();
743 let entries = match std::fs::read_dir(&cards_dir) {
744 Ok(e) => e,
745 Err(_) => return map,
746 };
747 for entry in entries.flatten() {
748 if !entry.path().is_dir() {
749 continue;
750 }
751 let pkg = match entry.file_name().to_str() {
752 Some(n) => n.to_string(),
753 None => continue,
754 };
755 let count = std::fs::read_dir(entry.path())
756 .map(|es| {
757 es.flatten()
758 .filter(|e| e.path().extension().is_some_and(|ext| ext == "toml"))
759 .count()
760 })
761 .unwrap_or(0);
762 if count > 0 {
763 map.insert(pkg, count);
764 }
765 }
766 map
767}
768
769fn count_evals_for_pkg(app_dir: &AppDir, pkg: &str, warnings: &mut Vec<String>) -> usize {
780 let evals_dir = app_dir.evals_dir();
781 let entries = match std::fs::read_dir(&evals_dir) {
782 Ok(e) => e,
783 Err(_) => return 0,
784 };
785
786 let mut meta_stems: HashSet<String> = HashSet::new();
789 let mut meta_matches: usize = 0;
790 let mut non_meta_paths: Vec<(PathBuf, String)> = Vec::new(); for entry in entries.flatten() {
793 let path = entry.path();
794 let name = match path.file_name().and_then(|n| n.to_str()) {
795 Some(n) => n.to_string(),
796 None => continue,
797 };
798
799 if name.ends_with(".meta.json") {
800 let stem = name.trim_end_matches(".meta.json").to_string();
801 meta_stems.insert(stem.clone());
802 match std::fs::read_to_string(&path) {
804 Ok(content) => match serde_json::from_str::<serde_json::Value>(&content) {
805 Ok(val) => {
806 if val.get("strategy").and_then(|s| s.as_str()) == Some(pkg) {
807 meta_matches += 1;
808 }
809 }
810 Err(e) => warnings.push(format!("eval meta parse {}: {e}", path.display())),
811 },
812 Err(e) => warnings.push(format!("eval meta read {}: {e}", path.display())),
813 }
814 continue;
815 }
816
817 if !name.ends_with(".json") || name.starts_with("compare_") {
819 continue;
820 }
821
822 let stem = path
823 .file_stem()
824 .and_then(|s| s.to_str())
825 .unwrap_or("")
826 .to_string();
827 non_meta_paths.push((path, stem));
828 }
829
830 let mut fallback_matches: usize = 0;
833 for (path, stem) in &non_meta_paths {
834 if meta_stems.contains(stem) {
835 continue;
836 }
837 match std::fs::read_to_string(path) {
838 Ok(c) => match serde_json::from_str::<serde_json::Value>(&c) {
839 Ok(v) => {
840 if v.get("strategy").and_then(|s| s.as_str()) == Some(pkg) {
841 fallback_matches += 1;
842 }
843 }
844 Err(e) => warnings.push(format!("eval result parse {}: {e}", path.display())),
845 },
846 Err(e) => warnings.push(format!("eval result read {}: {e}", path.display())),
847 }
848 }
849
850 meta_matches + fallback_matches
851}
852
853fn merge(app_dir: &AppDir, remote: &HubIndex) -> Result<Vec<SearchResult>, String> {
861 let installed = installed_packages(app_dir)?;
862 let card_counts = local_card_counts(app_dir);
863 let pkg_dir: Option<PathBuf> = Some(app_dir.packages_dir());
864
865 let mut seen: HashSet<String> = HashSet::new();
866 let mut results: Vec<SearchResult> = Vec::new();
867
868 for entry in &remote.packages {
869 let pkg_name = &entry.entity.name;
870 let is_installed = installed.contains_key(pkg_name);
871 let local_cards = card_counts.get(pkg_name).copied().unwrap_or(0);
872
873 let docstring = if entry.entity.docstring.as_deref().unwrap_or("").is_empty()
877 && is_installed
878 {
879 pkg_dir
880 .as_ref()
881 .and_then(|d| PkgEntity::parse_from_init_lua(&d.join(pkg_name).join("init.lua")))
882 .and_then(|e| e.docstring)
883 } else {
884 entry.entity.docstring.clone()
885 };
886
887 seen.insert(pkg_name.clone());
888 let mut merged_entity = entry.entity.clone();
889 merged_entity.docstring = docstring;
890 results.push(SearchResult {
891 entity: merged_entity,
892 source: entry.source.clone(),
893 installed: is_installed,
894 card_count: if is_installed && local_cards > entry.card_count {
895 local_cards
896 } else {
897 entry.card_count
898 },
899 best_card: entry.best_card.clone(),
900 docstring_matched: None,
901 });
902 }
903
904 for (name, version) in &installed {
906 if seen.contains(name) {
907 continue;
908 }
909 let parsed_entity = pkg_dir
916 .as_ref()
917 .and_then(|d| PkgEntity::parse_from_init_lua(&d.join(name).join("init.lua")));
918 let entity = parsed_entity.unwrap_or(PkgEntity {
919 name: name.clone(),
920 version: version.clone(),
921 description: None,
922 category: None,
923 docstring: None,
924 });
925 results.push(SearchResult {
926 entity,
927 source: PackageSource::Unknown,
928 installed: true,
929 card_count: card_counts.get(name).copied().unwrap_or(0),
930 best_card: None,
931 docstring_matched: None,
932 });
933 }
934
935 Ok(results)
936}
937
938fn matches_query(result: &SearchResult, query: &str) -> bool {
941 let q = query.to_lowercase();
942 let pkg = &result.entity;
943 let empty = String::new();
944 pkg.name.to_lowercase().contains(&q)
945 || pkg
946 .description
947 .as_ref()
948 .unwrap_or(&empty)
949 .to_lowercase()
950 .contains(&q)
951 || pkg
952 .category
953 .as_ref()
954 .unwrap_or(&empty)
955 .to_lowercase()
956 .contains(&q)
957 || pkg
958 .docstring
959 .as_ref()
960 .unwrap_or(&empty)
961 .to_lowercase()
962 .contains(&q)
963}
964
965fn build_index(app_dir: &AppDir, source_dir: Option<&std::path::Path>) -> Result<HubIndex, String> {
982 let empty = || HubIndex {
983 schema_version: "hub_index/v0".into(),
984 updated_at: super::manifest::now_iso8601(),
985 packages: Vec::new(),
986 };
987
988 let pkg_dir = match source_dir {
989 Some(d) => d.to_path_buf(),
990 None => app_dir.packages_dir(),
991 };
992
993 let use_local_state = source_dir.is_none();
994 let card_counts = if use_local_state {
995 local_card_counts(app_dir)
996 } else {
997 HashMap::new()
998 };
999 let manifest = if use_local_state {
1006 manifest::load_manifest(app_dir)?
1007 } else {
1008 manifest::Manifest::default()
1009 };
1010
1011 let mut entries = Vec::new();
1012
1013 let dir_entries = match std::fs::read_dir(&pkg_dir) {
1017 Ok(e) => e,
1018 Err(_) => return Ok(empty()),
1019 };
1020
1021 for entry in dir_entries.flatten() {
1022 if !entry.path().is_dir() {
1023 continue;
1024 }
1025 let dir_name = match entry.file_name().to_str() {
1026 Some(n) if !n.starts_with('.') && !n.starts_with('_') => n.to_string(),
1027 _ => continue,
1028 };
1029
1030 let init_lua = entry.path().join("init.lua");
1031 if !init_lua.exists() {
1032 continue;
1033 }
1034
1035 let Some(entity) = PkgEntity::parse_from_init_lua(&init_lua) else {
1042 continue;
1043 };
1044
1045 let source = manifest
1049 .packages
1050 .get(&dir_name)
1051 .map(|e| e.source.clone())
1052 .unwrap_or_default();
1053
1054 entries.push(IndexEntry {
1055 entity,
1056 source,
1057 card_count: card_counts.get(&dir_name).copied().unwrap_or(0),
1058 best_card: None,
1059 });
1060 }
1061
1062 entries.sort_by(|a, b| a.entity.name.cmp(&b.entity.name));
1063
1064 Ok(HubIndex {
1065 schema_version: "hub_index/v0".into(),
1066 updated_at: super::manifest::now_iso8601(),
1067 packages: entries,
1068 })
1069}
1070
1071impl AppService {
1074 pub fn hub_reindex(
1083 &self,
1084 output_path: Option<&str>,
1085 source_dir: Option<&str>,
1086 ) -> Result<String, String> {
1087 let src = source_dir.map(std::path::Path::new);
1088 if let Some(d) = src {
1089 if !d.is_dir() {
1090 return Err(format!("source_dir '{}' is not a directory", d.display()));
1091 }
1092 }
1093 let app_dir = self.log_config.app_dir();
1094 let index = build_index(&app_dir, src)?;
1095
1096 let written_path = if let Some(path) = output_path {
1097 let json = serde_json::to_string_pretty(&index)
1098 .map_err(|e| format!("Failed to serialize index: {e}"))?;
1099 std::fs::write(path, &json)
1100 .map_err(|e| format!("Failed to write index to {path}: {e}"))?;
1101 Some(path.to_string())
1102 } else {
1103 None
1104 };
1105
1106 let response = serde_json::json!({
1107 "package_count": index.packages.len(),
1108 "updated_at": index.updated_at,
1109 "output_path": written_path,
1110 "source_dir": source_dir,
1111 });
1112 Ok(response.to_string())
1113 }
1114
1115 pub fn hub_info(&self, pkg: &str) -> Result<String, String> {
1120 use algocline_engine::card;
1121
1122 if pkg.contains("..") || pkg.contains('/') || pkg.contains('\\') {
1124 return Err(format!("Invalid package name: '{pkg}'"));
1125 }
1126
1127 let app_dir = self.log_config.app_dir();
1129 let installed = installed_packages(&app_dir)?;
1130 let is_installed = installed.contains_key(pkg);
1131
1132 let (version, description, category, source) = {
1138 let (remote, _) = fetch_remote_indices(&app_dir)?;
1139 if let Some(entry) = remote.packages.iter().find(|e| e.entity.name == pkg) {
1140 (
1141 entry.entity.version.clone().unwrap_or_default(),
1142 entry.entity.description.clone().unwrap_or_default(),
1143 entry.entity.category.clone().unwrap_or_default(),
1144 entry.source.clone(),
1145 )
1146 } else if is_installed {
1147 let init_lua = app_dir.packages_dir().join(pkg).join("init.lua");
1153 let entity = PkgEntity::parse_from_init_lua(&init_lua);
1154 let manifest_source = manifest::load_manifest(&app_dir)?
1155 .packages
1156 .get(pkg)
1157 .map(|e| e.source.clone())
1158 .unwrap_or_default();
1159 match entity {
1160 Some(e) => (
1161 e.version.unwrap_or_default(),
1162 e.description.unwrap_or_default(),
1163 e.category.unwrap_or_default(),
1164 manifest_source,
1165 ),
1166 None => (
1167 installed.get(pkg).cloned().flatten().unwrap_or_default(),
1168 String::new(),
1169 String::new(),
1170 manifest_source,
1171 ),
1172 }
1173 } else {
1174 return Err(format!(
1175 "Package '{pkg}' not found in remote indices or locally installed packages"
1176 ));
1177 }
1178 };
1179
1180 let mut warnings: Vec<String> = Vec::new();
1184
1185 let card_rows = match self.card_store.list(Some(pkg)) {
1187 Ok(rows) => rows,
1188 Err(e) => {
1189 let msg = format!("card store list for '{pkg}': {e}");
1190 tracing::warn!("{}", msg);
1191 warnings.push(msg);
1192 vec![]
1193 }
1194 };
1195 let cards_json = card::summaries_to_json(&card_rows);
1196
1197 let aliases_json = match self.card_store.alias_list(Some(pkg)) {
1199 Ok(rows) => card::aliases_to_json(&rows),
1200 Err(e) => {
1201 let msg = format!("card store alias_list for '{pkg}': {e}");
1202 tracing::warn!("{}", msg);
1203 warnings.push(msg);
1204 serde_json::json!([])
1205 }
1206 };
1207
1208 let card_count = card_rows.len();
1210 let best_pass_rate = card_rows
1211 .iter()
1212 .filter_map(|c| c.pass_rate)
1213 .fold(f64::NEG_INFINITY, f64::max);
1214 let best_pass_rate = if best_pass_rate.is_finite() {
1215 Some(best_pass_rate)
1216 } else {
1217 None
1218 };
1219
1220 let eval_count = count_evals_for_pkg(&app_dir, pkg, &mut warnings);
1222
1223 let mut response = serde_json::json!({
1224 "pkg": {
1225 "name": pkg,
1226 "version": version,
1227 "description": description,
1228 "category": category,
1229 "source": source,
1230 "installed": is_installed,
1231 },
1232 "cards": cards_json,
1233 "aliases": aliases_json,
1234 "stats": {
1235 "card_count": card_count,
1236 "eval_count": eval_count,
1237 "best_pass_rate": best_pass_rate,
1238 },
1239 });
1240 if !warnings.is_empty() {
1241 response["warnings"] = serde_json::json!(warnings);
1242 }
1243 Ok(response.to_string())
1244 }
1245
1246 pub(crate) fn hub_search(
1278 &self,
1279 query: Option<&str>,
1280 category: Option<&str>,
1281 installed_only: Option<bool>,
1282 opts: ListOpts,
1283 ) -> Result<String, String> {
1284 let app_dir = self.log_config.app_dir();
1285 let (remote, warnings) = fetch_remote_indices(&app_dir)?;
1286 let mut results = merge(&app_dir, &remote)?;
1287
1288 let query_lower = query.filter(|q| !q.is_empty()).map(|q| q.to_lowercase());
1291 if let Some(ref ql) = query_lower {
1292 results.retain(|r| matches_query(r, ql));
1293 }
1294
1295 if let Some(ref ql) = query_lower {
1299 for r in &mut results {
1300 let empty = String::new();
1301 let pkg = &r.entity;
1302 let other_hit = pkg.name.to_lowercase().contains(ql)
1303 || pkg
1304 .description
1305 .as_ref()
1306 .unwrap_or(&empty)
1307 .to_lowercase()
1308 .contains(ql)
1309 || pkg
1310 .category
1311 .as_ref()
1312 .unwrap_or(&empty)
1313 .to_lowercase()
1314 .contains(ql);
1315 let doc_hit = pkg
1316 .docstring
1317 .as_ref()
1318 .unwrap_or(&empty)
1319 .to_lowercase()
1320 .contains(ql);
1321 r.docstring_matched = if !other_hit && doc_hit {
1322 Some(true)
1323 } else {
1324 None
1325 };
1326 }
1327 }
1328
1329 let mut filter_map: std::collections::HashMap<String, serde_json::Value> =
1333 opts.filter.unwrap_or_default();
1334 if let Some(cat) = category {
1335 filter_map
1336 .entry("category".to_string())
1337 .or_insert_with(|| serde_json::Value::String(cat.to_string()));
1338 }
1339 if let Some(only) = installed_only {
1340 if only {
1344 filter_map
1345 .entry("installed".to_string())
1346 .or_insert(serde_json::Value::Bool(true));
1347 }
1348 }
1349
1350 let sort_str = opts.sort.as_deref().unwrap_or("-installed,name");
1353 let sort_keys = parse_sort(sort_str)?;
1354
1355 let fields = resolve_fields(
1358 opts.verbose.as_deref(),
1359 opts.fields.as_deref(),
1360 HUB_SEARCH_SUMMARY,
1361 HUB_SEARCH_FULL,
1362 )?;
1363 let include_docstring = fields.iter().any(|f| f == "docstring");
1364
1365 let mut items: Vec<serde_json::Value> = results
1368 .iter()
1369 .map(|r| r.to_value_with_optional_docstring(include_docstring))
1370 .collect();
1371
1372 if !filter_map.is_empty() {
1375 items.retain(|v| matches_filter(v, &filter_map));
1376 }
1377
1378 apply_sort_by_value(&mut items, &sort_keys);
1380
1381 let total = items.len();
1385 let limit = opts.limit.unwrap_or(50);
1386 if limit > 0 {
1387 items.truncate(limit);
1388 }
1389
1390 let projected: Vec<serde_json::Value> = items
1393 .into_iter()
1394 .map(|v| project_fields(v, &fields))
1395 .collect();
1396
1397 let mut _src_warnings: Vec<String> = Vec::new();
1402 let sources = discover_index_urls(&app_dir, &mut _src_warnings)?;
1403
1404 let mut json = serde_json::json!({
1405 "results": projected,
1406 "total": total,
1407 "sources": sources,
1408 });
1409 if !warnings.is_empty() {
1410 json["warnings"] = serde_json::json!(warnings);
1411 }
1412 Ok(json.to_string())
1413 }
1414
1415 pub(crate) fn aggregate_index(
1431 &self,
1432 ) -> Result<(HubIndex, Vec<String>), super::error::ServiceError> {
1433 let app_dir = self.log_config.app_dir();
1434 let mut warnings: Vec<String> = Vec::new();
1435
1436 let urls = match discover_index_urls(&app_dir, &mut warnings) {
1441 Ok(u) => u,
1442 Err(e) => {
1443 warnings.push(format!("hub registry discovery failed: {e}"));
1444 return Ok((
1445 HubIndex {
1446 schema_version: "hub_index/v0".into(),
1447 updated_at: String::new(),
1448 packages: Vec::new(),
1449 },
1450 warnings,
1451 ));
1452 }
1453 };
1454
1455 if urls.is_empty() {
1457 return Ok((
1458 HubIndex {
1459 schema_version: "hub_index/v0".into(),
1460 updated_at: String::new(),
1461 packages: Vec::new(),
1462 },
1463 warnings,
1464 ));
1465 }
1466
1467 let mut all_packages: Vec<IndexEntry> = Vec::new();
1474 let mut seen_names: std::collections::HashSet<String> = std::collections::HashSet::new();
1475
1476 for url in &urls {
1477 let merge_packages =
1478 |packages: Vec<IndexEntry>,
1479 all: &mut Vec<IndexEntry>,
1480 seen: &mut std::collections::HashSet<String>| {
1481 for entry in packages {
1482 if seen.insert(entry.entity.name.clone()) {
1483 all.push(entry);
1484 }
1485 }
1486 };
1487 match load_cached_full(&app_dir, url) {
1488 CacheLookup::Fresh(index) => {
1489 merge_packages(index.packages, &mut all_packages, &mut seen_names);
1490 }
1491 CacheLookup::Stale(index) => {
1492 warnings.push(format!(
1495 "hub cache stale (>{CACHE_TTL_SECS}s) for {url}; run alc_hub_search to refresh"
1496 ));
1497 merge_packages(index.packages, &mut all_packages, &mut seen_names);
1498 }
1499 CacheLookup::NotPresent => {
1500 }
1502 CacheLookup::Corrupt(e) => {
1503 warnings.push(format!("hub cache read failed for {url}: {e}"));
1505 }
1506 }
1507 }
1508
1509 Ok((
1510 HubIndex {
1511 schema_version: "hub_index/v0".into(),
1512 updated_at: String::new(),
1513 packages: all_packages,
1514 },
1515 warnings,
1516 ))
1517 }
1518}
1519
1520#[cfg(test)]
1521mod tests {
1522 use super::*;
1523
1524 #[test]
1525 fn repo_to_index_url_github() {
1526 assert_eq!(
1527 repo_to_index_url("https://github.com/ynishi/algocline-bundled-packages"),
1528 Some(
1529 "https://raw.githubusercontent.com/ynishi/algocline-bundled-packages/main/hub_index.json"
1530 .to_string()
1531 )
1532 );
1533 }
1534
1535 #[test]
1536 fn repo_to_index_url_github_trailing_slash() {
1537 assert_eq!(
1538 repo_to_index_url("https://github.com/user/repo/"),
1539 Some("https://raw.githubusercontent.com/user/repo/main/hub_index.json".to_string())
1540 );
1541 }
1542
1543 #[test]
1544 fn repo_to_index_url_github_dot_git() {
1545 assert_eq!(
1546 repo_to_index_url("https://github.com/user/repo.git"),
1547 Some("https://raw.githubusercontent.com/user/repo/main/hub_index.json".to_string())
1548 );
1549 }
1550
1551 #[test]
1552 fn repo_to_index_url_direct_json() {
1553 assert_eq!(
1554 repo_to_index_url("https://example.com/my_index.json"),
1555 Some("https://example.com/my_index.json".to_string())
1556 );
1557 }
1558
1559 #[test]
1560 fn repo_to_index_url_unknown_host_no_json() {
1561 assert_eq!(repo_to_index_url("https://example.com/some-repo"), None);
1562 }
1563
1564 #[test]
1565 fn repo_to_index_url_local_path() {
1566 assert_eq!(repo_to_index_url("/home/user/my-pkg"), None);
1567 }
1568
1569 #[test]
1570 fn cache_key_stable() {
1571 let k1 = cache_key("https://example.com/index.json");
1572 let k2 = cache_key("https://example.com/index.json");
1573 assert_eq!(k1, k2);
1574 assert_eq!(k1.len(), 16); }
1576
1577 #[test]
1578 fn cache_key_different_urls() {
1579 let k1 = cache_key("https://a.com/index.json");
1580 let k2 = cache_key("https://b.com/index.json");
1581 assert_ne!(k1, k2);
1582 }
1583
1584 #[test]
1590 fn merge_dedup_uses_hashset() {
1591 let tmp = tempfile::tempdir().unwrap();
1594 let app_dir = AppDir::new(tmp.path().to_path_buf());
1595 let remote = HubIndex {
1596 schema_version: "hub_index/v0".into(),
1597 updated_at: String::new(),
1598 packages: vec![IndexEntry {
1599 entity: PkgEntity {
1600 name: "remote_only".into(),
1601 version: Some("1.0".into()),
1602 description: Some("from remote".into()),
1603 category: Some("test".into()),
1604 docstring: None,
1605 },
1606 source: PackageSource::Unknown,
1607 card_count: 0,
1608 best_card: None,
1609 }],
1610 };
1611
1612 let results = merge(&app_dir, &remote).expect("merge over empty app_dir should succeed");
1613 assert!(results.iter().any(|r| r.entity.name == "remote_only"));
1615 }
1616
1617 #[test]
1618 fn matches_query_searches_docstring() {
1619 let result = SearchResult {
1620 entity: PkgEntity {
1621 name: "cascade".into(),
1622 version: Some("0.1.0".into()),
1623 description: Some("Multi-level routing".into()),
1624 category: Some("meta".into()),
1625 docstring: Some("Based on FrugalGPT. Uses Thompson Sampling.".into()),
1626 },
1627 source: PackageSource::Unknown,
1628 installed: true,
1629 card_count: 0,
1630 best_card: None,
1631 docstring_matched: None,
1632 };
1633
1634 assert!(matches_query(&result, "thompson"), "docstring match");
1635 assert!(matches_query(&result, "FrugalGPT"), "docstring match case");
1636 assert!(matches_query(&result, "routing"), "description match");
1637 assert!(!matches_query(&result, "bayesian"), "no match");
1638 }
1639
1640 fn sample_search_result() -> SearchResult {
1649 SearchResult {
1650 entity: PkgEntity {
1651 name: "cascade".into(),
1652 version: Some("0.1.0".into()),
1653 description: Some("Multi-level routing".into()),
1654 category: Some("reasoning".into()),
1655 docstring: Some("Based on FrugalGPT. Uses Thompson Sampling.".into()),
1656 },
1657 source: PackageSource::Git {
1658 url: "https://example.com/cascade".into(),
1659 rev: None,
1660 },
1661 installed: true,
1662 card_count: 3,
1663 best_card: None,
1664 docstring_matched: None,
1665 }
1666 }
1667
1668 #[test]
1669 fn to_value_default_omits_docstring() {
1670 let r = sample_search_result();
1671 let v = r.to_value_with_optional_docstring(false);
1672 let obj = v.as_object().expect("object");
1673 assert!(
1674 !obj.contains_key("docstring"),
1675 "default summary must not leak docstring"
1676 );
1677 assert_eq!(obj.get("name").and_then(|x| x.as_str()), Some("cascade"));
1678 assert!(
1681 !obj.contains_key("docstring_matched"),
1682 "docstring_matched=None must be omitted"
1683 );
1684 }
1685
1686 #[test]
1687 fn to_value_include_reattaches_docstring() {
1688 let r = sample_search_result();
1689 let v = r.to_value_with_optional_docstring(true);
1690 let obj = v.as_object().expect("object");
1691 assert_eq!(
1692 obj.get("docstring").and_then(|x| x.as_str()),
1693 Some("Based on FrugalGPT. Uses Thompson Sampling.")
1694 );
1695 }
1696
1697 #[test]
1698 fn to_value_serializes_docstring_matched_when_set() {
1699 let mut r = sample_search_result();
1700 r.docstring_matched = Some(true);
1701 let v = r.to_value_with_optional_docstring(false);
1702 let obj = v.as_object().expect("object");
1703 assert_eq!(
1704 obj.get("docstring_matched").and_then(|x| x.as_bool()),
1705 Some(true)
1706 );
1707 }
1708
1709 #[test]
1719 fn hub_search_default_summary_excludes_docstring() {
1720 let r = sample_search_result();
1721 let fields = resolve_fields(None, None, HUB_SEARCH_SUMMARY, HUB_SEARCH_FULL).unwrap();
1722 let include_docstring = fields.iter().any(|f| f == "docstring");
1723 let v = project_fields(
1724 r.to_value_with_optional_docstring(include_docstring),
1725 &fields,
1726 );
1727 let obj = v.as_object().expect("object");
1728 assert!(
1729 !obj.contains_key("docstring"),
1730 "summary preset must omit docstring"
1731 );
1732 for key in ["name", "version", "description", "category", "installed"] {
1734 assert!(obj.contains_key(key), "summary preset key {key} missing");
1735 }
1736 }
1737
1738 #[test]
1739 fn hub_search_verbose_full_includes_docstring() {
1740 let r = sample_search_result();
1741 let fields =
1742 resolve_fields(Some("full"), None, HUB_SEARCH_SUMMARY, HUB_SEARCH_FULL).unwrap();
1743 let include_docstring = fields.iter().any(|f| f == "docstring");
1744 let v = project_fields(
1745 r.to_value_with_optional_docstring(include_docstring),
1746 &fields,
1747 );
1748 let obj = v.as_object().expect("object");
1749 assert_eq!(
1750 obj.get("docstring").and_then(|x| x.as_str()),
1751 Some("Based on FrugalGPT. Uses Thompson Sampling.")
1752 );
1753 for key in ["source", "card_count"] {
1755 assert!(obj.contains_key(key), "full preset key {key} missing");
1756 }
1757 }
1758
1759 #[test]
1760 fn hub_search_fields_beats_verbose() {
1761 let r = sample_search_result();
1762 let explicit = vec!["name".to_string(), "docstring".to_string()];
1763 let fields = resolve_fields(
1766 Some("summary"),
1767 Some(&explicit),
1768 HUB_SEARCH_SUMMARY,
1769 HUB_SEARCH_FULL,
1770 )
1771 .unwrap();
1772 let include_docstring = fields.iter().any(|f| f == "docstring");
1773 let v = project_fields(
1774 r.to_value_with_optional_docstring(include_docstring),
1775 &fields,
1776 );
1777 let obj = v.as_object().expect("object");
1778 assert_eq!(obj.len(), 2, "only the two requested fields");
1779 assert!(obj.contains_key("name"));
1780 assert!(obj.contains_key("docstring"));
1781 }
1782
1783 #[test]
1784 fn hub_search_fields_unknown_key_silently_skipped() {
1785 let r = sample_search_result();
1786 let explicit = vec!["name".to_string(), "bogus".to_string()];
1787 let fields =
1788 resolve_fields(None, Some(&explicit), HUB_SEARCH_SUMMARY, HUB_SEARCH_FULL).unwrap();
1789 let v = project_fields(r.to_value_with_optional_docstring(false), &fields);
1790 let obj = v.as_object().expect("object");
1791 assert_eq!(obj.len(), 1, "bogus must not appear");
1792 assert!(obj.contains_key("name"));
1793 }
1794
1795 #[test]
1796 fn hub_search_invalid_verbose_errors() {
1797 let err =
1798 resolve_fields(Some("fat"), None, HUB_SEARCH_SUMMARY, HUB_SEARCH_FULL).unwrap_err();
1799 assert!(
1800 err.contains("fat"),
1801 "error must mention the offending value"
1802 );
1803 }
1804
1805 fn classify(r: &SearchResult, query: &str) -> Option<bool> {
1814 let ql = query.to_lowercase();
1815 if query.is_empty() {
1816 return None;
1817 }
1818 let empty = String::new();
1819 let pkg = &r.entity;
1820 let other_hit = pkg.name.to_lowercase().contains(&ql)
1821 || pkg
1822 .description
1823 .as_ref()
1824 .unwrap_or(&empty)
1825 .to_lowercase()
1826 .contains(&ql)
1827 || pkg
1828 .category
1829 .as_ref()
1830 .unwrap_or(&empty)
1831 .to_lowercase()
1832 .contains(&ql);
1833 let doc_hit = pkg
1834 .docstring
1835 .as_ref()
1836 .unwrap_or(&empty)
1837 .to_lowercase()
1838 .contains(&ql);
1839 if !other_hit && doc_hit {
1840 Some(true)
1841 } else {
1842 None
1843 }
1844 }
1845
1846 #[test]
1847 fn docstring_matched_true_when_only_docstring_hits() {
1848 let r = sample_search_result();
1849 assert_eq!(classify(&r, "thompson"), Some(true));
1851 }
1852
1853 #[test]
1854 fn docstring_matched_none_when_name_also_hits() {
1855 let r = sample_search_result();
1856 assert_eq!(classify(&r, "cascade"), None);
1858 }
1859
1860 #[test]
1861 fn docstring_matched_none_when_description_hits() {
1862 let r = sample_search_result();
1863 assert_eq!(classify(&r, "routing"), None);
1865 }
1866
1867 #[test]
1868 fn docstring_matched_none_when_query_empty() {
1869 let r = sample_search_result();
1870 assert_eq!(classify(&r, ""), None);
1871 }
1872
1873 fn build_filter_map(
1881 category: Option<&str>,
1882 installed_only: Option<bool>,
1883 explicit: Option<HashMap<String, serde_json::Value>>,
1884 ) -> HashMap<String, serde_json::Value> {
1885 let mut filter_map = explicit.unwrap_or_default();
1886 if let Some(cat) = category {
1887 filter_map
1888 .entry("category".to_string())
1889 .or_insert_with(|| serde_json::Value::String(cat.to_string()));
1890 }
1891 if let Some(only) = installed_only {
1892 if only {
1893 filter_map
1894 .entry("installed".to_string())
1895 .or_insert(serde_json::Value::Bool(true));
1896 }
1897 }
1898 filter_map
1899 }
1900
1901 #[test]
1902 fn filter_by_category_via_legacy_param() {
1903 let m = build_filter_map(Some("reasoning"), None, None);
1904 assert_eq!(
1905 m.get("category"),
1906 Some(&serde_json::Value::String("reasoning".to_string()))
1907 );
1908 }
1909
1910 #[test]
1911 fn filter_by_installed_only_via_legacy_param() {
1912 let m = build_filter_map(None, Some(true), None);
1913 assert_eq!(m.get("installed"), Some(&serde_json::Value::Bool(true)));
1914 }
1915
1916 #[test]
1917 fn filter_installed_only_false_is_noop() {
1918 let m = build_filter_map(None, Some(false), None);
1919 assert!(
1920 !m.contains_key("installed"),
1921 "installed_only=false should not fold in"
1922 );
1923 }
1924
1925 #[test]
1926 fn filter_beats_legacy_param_on_conflict() {
1927 let mut explicit = HashMap::new();
1930 explicit.insert(
1931 "category".to_string(),
1932 serde_json::Value::String("meta".to_string()),
1933 );
1934 let m = build_filter_map(Some("reasoning"), None, Some(explicit));
1935 assert_eq!(
1936 m.get("category"),
1937 Some(&serde_json::Value::String("meta".to_string()))
1938 );
1939 }
1940
1941 #[test]
1942 fn filter_merges_legacy_when_no_conflict() {
1943 let mut explicit = HashMap::new();
1946 explicit.insert("installed".to_string(), serde_json::Value::Bool(true));
1947 let m = build_filter_map(Some("reasoning"), None, Some(explicit));
1948 assert_eq!(
1949 m.get("category"),
1950 Some(&serde_json::Value::String("reasoning".to_string()))
1951 );
1952 assert_eq!(m.get("installed"), Some(&serde_json::Value::Bool(true)));
1953 }
1954
1955 #[test]
1958 fn load_registries_missing_file_returns_default() {
1959 let tmp = tempfile::tempdir().unwrap();
1960 let app_dir = AppDir::new(tmp.path().to_path_buf());
1961 let result = load_registries(&app_dir);
1963 assert!(result.is_ok(), "missing file should be Ok: {result:?}");
1964 assert!(result.unwrap().registries.is_empty());
1965 }
1966
1967 #[test]
1968 fn load_registries_corrupt_json_returns_err() {
1969 let tmp = tempfile::tempdir().unwrap();
1970 let app_dir = AppDir::new(tmp.path().to_path_buf());
1971 let path = app_dir.hub_registries_json();
1973 std::fs::create_dir_all(path.parent().unwrap()).unwrap();
1974 std::fs::write(&path, b"not valid json {{{").unwrap();
1975 let result = load_registries(&app_dir);
1976 assert!(result.is_err(), "corrupt JSON must propagate Err");
1977 let msg = result.unwrap_err().to_string();
1978 assert!(
1979 msg.contains("parse"),
1980 "error message should mention parse: {msg}"
1981 );
1982 }
1983
1984 #[test]
1985 fn load_registries_valid_file_deserializes() {
1986 let tmp = tempfile::tempdir().unwrap();
1987 let app_dir = AppDir::new(tmp.path().to_path_buf());
1988 let path = app_dir.hub_registries_json();
1989 std::fs::create_dir_all(path.parent().unwrap()).unwrap();
1990 let content = r#"{"registries":[{"source":"https://github.com/user/repo","origin":"pkg_install","added_at":"2026-01-01T00:00:00Z"}]}"#;
1991 std::fs::write(&path, content).unwrap();
1992 let result = load_registries(&app_dir);
1993 assert!(result.is_ok(), "valid JSON must parse Ok: {result:?}");
1994 let reg = result.unwrap();
1995 assert_eq!(reg.registries.len(), 1);
1996 assert_eq!(reg.registries[0].source, "https://github.com/user/repo");
1997 }
1998
1999 #[test]
2002 fn default_sort_is_minus_installed_name() {
2003 let keys = parse_sort("-installed,name").unwrap();
2004 assert_eq!(keys.len(), 2);
2005 assert_eq!(keys[0].key, "installed");
2006 assert!(keys[0].desc, "installed must sort desc (true first)");
2007 assert_eq!(keys[1].key, "name");
2008 assert!(!keys[1].desc);
2009
2010 let mut items = vec![
2012 serde_json::json!({"installed": false, "name": "zeta"}),
2013 serde_json::json!({"installed": true, "name": "mu"}),
2014 serde_json::json!({"installed": false, "name": "alpha"}),
2015 serde_json::json!({"installed": true, "name": "beta"}),
2016 ];
2017 apply_sort_by_value(&mut items, &keys);
2018 let names: Vec<&str> = items
2019 .iter()
2020 .map(|v| v.get("name").and_then(|x| x.as_str()).unwrap_or(""))
2021 .collect();
2022 assert_eq!(names, vec!["beta", "mu", "alpha", "zeta"]);
2023 }
2024
2025 #[test]
2030 fn collection_url_from_config_absent_returns_ok_none() {
2031 let tmp = tempfile::tempdir().unwrap();
2032 let app_dir = AppDir::new(tmp.path().to_path_buf());
2033 let result = collection_url_from_config(&app_dir);
2035 assert!(
2036 matches!(result, Ok(None)),
2037 "absent config.toml must return Ok(None), got {result:?}"
2038 );
2039 }
2040
2041 #[test]
2042 fn collection_url_from_config_corrupt_toml_returns_err() {
2043 let tmp = tempfile::tempdir().unwrap();
2044 let app_dir = AppDir::new(tmp.path().to_path_buf());
2045 let path = app_dir.config_toml();
2046 std::fs::create_dir_all(path.parent().unwrap()).unwrap();
2047 std::fs::write(&path, b"[hub\ncollection_url = broken{{{{").unwrap();
2048 let result = collection_url_from_config(&app_dir);
2049 assert!(
2050 result.is_err(),
2051 "corrupt TOML must return Err, got {result:?}"
2052 );
2053 }
2054
2055 #[test]
2056 fn collection_url_from_config_valid_returns_url() {
2057 let tmp = tempfile::tempdir().unwrap();
2058 let app_dir = AppDir::new(tmp.path().to_path_buf());
2059 let path = app_dir.config_toml();
2060 std::fs::create_dir_all(path.parent().unwrap()).unwrap();
2061 std::fs::write(
2062 &path,
2063 b"[hub]\ncollection_url = \"https://example.com/hub_index.json\"\n",
2064 )
2065 .unwrap();
2066 let result = collection_url_from_config(&app_dir);
2067 assert_eq!(
2068 result.unwrap(),
2069 Some("https://example.com/hub_index.json".to_string())
2070 );
2071 }
2072
2073 #[test]
2074 fn collection_url_from_config_no_hub_section_returns_none() {
2075 let tmp = tempfile::tempdir().unwrap();
2076 let app_dir = AppDir::new(tmp.path().to_path_buf());
2077 let path = app_dir.config_toml();
2078 std::fs::create_dir_all(path.parent().unwrap()).unwrap();
2079 std::fs::write(&path, b"[some_other_section]\nfoo = \"bar\"\n").unwrap();
2080 let result = collection_url_from_config(&app_dir);
2081 assert!(
2082 matches!(result, Ok(None)),
2083 "config without [hub] must return Ok(None), got {result:?}"
2084 );
2085 }
2086
2087 #[test]
2090 fn load_cached_absent_returns_ok_none() {
2091 let tmp = tempfile::tempdir().unwrap();
2092 let app_dir = AppDir::new(tmp.path().to_path_buf());
2093 let result = load_cached(&app_dir, "https://example.com/index.json");
2094 assert!(
2095 matches!(result, Ok(None)),
2096 "absent cache file must return Ok(None), got {result:?}"
2097 );
2098 }
2099
2100 #[test]
2101 fn load_cached_corrupt_json_within_ttl_returns_err() {
2102 let tmp = tempfile::tempdir().unwrap();
2103 let app_dir = AppDir::new(tmp.path().to_path_buf());
2104 let url = "https://example.com/index.json";
2105 let dir = cache_dir(&app_dir);
2106 std::fs::create_dir_all(&dir).unwrap();
2107 let path = dir.join(format!("{}.json", cache_key(url)));
2108 std::fs::write(&path, b"not valid json {{{{").unwrap();
2109 let result = load_cached(&app_dir, url);
2111 assert!(
2112 result.is_err(),
2113 "corrupt JSON within TTL must return Err, got {result:?}"
2114 );
2115 }
2116
2117 #[test]
2118 fn load_cached_valid_json_within_ttl_returns_index() {
2119 let tmp = tempfile::tempdir().unwrap();
2120 let app_dir = AppDir::new(tmp.path().to_path_buf());
2121 let url = "https://example.com/index.json";
2122 let dir = cache_dir(&app_dir);
2123 std::fs::create_dir_all(&dir).unwrap();
2124 let path = dir.join(format!("{}.json", cache_key(url)));
2125 let index_json = r#"{"schema_version":"hub_index/v0","updated_at":"2026-01-01T00:00:00Z","packages":[]}"#;
2126 std::fs::write(&path, index_json).unwrap();
2127 let result = load_cached(&app_dir, url);
2128 assert!(
2129 matches!(result, Ok(Some(_))),
2130 "valid JSON within TTL must return Ok(Some(_)), got {result:?}"
2131 );
2132 }
2133
2134 fn backdate_file(path: &std::path::Path, secs: u64) {
2136 let past = std::time::SystemTime::now() - std::time::Duration::from_secs(secs);
2137 let times = std::fs::FileTimes::new()
2138 .set_accessed(past)
2139 .set_modified(past);
2140 let f = std::fs::OpenOptions::new()
2141 .write(true)
2142 .open(path)
2143 .expect("open for backdate");
2144 f.set_times(times).expect("set_times");
2145 }
2146
2147 #[test]
2149 fn load_cached_full_stale_file_returns_stale_variant() {
2150 let tmp = tempfile::tempdir().unwrap();
2151 let app_dir = AppDir::new(tmp.path().to_path_buf());
2152 let url = "https://stale.example.com/index.json";
2153 write_cache_for_url(&app_dir, url, &make_index(vec![("stale_pkg", "0.1.0")]));
2155 let path = cache_dir(&app_dir).join(format!("{}.json", cache_key(url)));
2157 backdate_file(&path, CACHE_TTL_SECS * 2);
2158 let result = load_cached_full(&app_dir, url);
2159 assert!(
2160 matches!(result, CacheLookup::Stale(_)),
2161 "backdated cache must return Stale variant"
2162 );
2163 }
2164
2165 #[tokio::test]
2167 async fn aggregate_index_stale_cache_returns_data_and_warning() {
2168 let tmp = tempfile::tempdir().unwrap();
2169 let app_dir_root = tmp.path().to_path_buf();
2170 let app_dir = AppDir::new(app_dir_root.clone());
2171 let url = "https://stale-agg.example.com/index.json";
2172
2173 write_cache_for_url(&app_dir, url, &make_index(vec![("stale_pkg", "0.1.0")]));
2175 let cache_path = cache_dir(&app_dir).join(format!("{}.json", cache_key(url)));
2177 backdate_file(&cache_path, CACHE_TTL_SECS * 2);
2178
2179 let reg_path = app_dir.hub_registries_json();
2181 std::fs::create_dir_all(reg_path.parent().unwrap()).unwrap();
2182 let reg_json = serde_json::json!({
2183 "registries": [{"source": url, "origin": "pkg_install", "added_at": "2026-01-01T00:00:00Z"}]
2184 });
2185 std::fs::write(®_path, reg_json.to_string()).unwrap();
2186
2187 let svc = super::super::test_support::make_app_service_at(app_dir_root).await;
2188 let (index, warnings) = AppService::aggregate_index(&svc).unwrap();
2189
2190 assert!(
2192 index.packages.iter().any(|p| p.entity.name == "stale_pkg"),
2193 "stale package must be included in aggregate, got: {:?}",
2194 index
2195 .packages
2196 .iter()
2197 .map(|p| &p.entity.name)
2198 .collect::<Vec<_>>()
2199 );
2200 assert!(
2202 warnings
2203 .iter()
2204 .any(|w| w.contains("stale") && w.contains(url)),
2205 "stale cache must emit a warning mentioning the URL, got: {warnings:?}"
2206 );
2207 }
2208
2209 #[test]
2212 fn count_evals_for_pkg_absent_dir_returns_zero_no_warnings() {
2213 let tmp = tempfile::tempdir().unwrap();
2214 let app_dir = AppDir::new(tmp.path().to_path_buf());
2215 let mut warnings: Vec<String> = Vec::new();
2216 let count = count_evals_for_pkg(&app_dir, "cot", &mut warnings);
2217 assert_eq!(count, 0, "absent evals dir must return 0");
2218 assert!(
2219 warnings.is_empty(),
2220 "absent evals dir must produce no warnings, got {warnings:?}"
2221 );
2222 }
2223
2224 #[test]
2225 fn count_evals_for_pkg_corrupt_meta_surfaces_warning() {
2226 let tmp = tempfile::tempdir().unwrap();
2227 let app_dir = AppDir::new(tmp.path().to_path_buf());
2228 let evals_dir = app_dir.evals_dir();
2229 std::fs::create_dir_all(&evals_dir).unwrap();
2230
2231 std::fs::write(evals_dir.join("cot_9999.json"), b"{}").unwrap();
2233 std::fs::write(evals_dir.join("cot_9999.meta.json"), b"not json {{{{").unwrap();
2235
2236 let mut warnings: Vec<String> = Vec::new();
2237 let _count = count_evals_for_pkg(&app_dir, "cot", &mut warnings);
2238 assert!(
2239 !warnings.is_empty(),
2240 "corrupt meta.json must produce at least one warning, got {warnings:?}"
2241 );
2242 assert!(
2243 warnings[0].contains("parse"),
2244 "warning must mention parse: {}",
2245 warnings[0]
2246 );
2247 }
2248
2249 #[test]
2250 fn count_evals_for_pkg_valid_meta_counts_correctly() {
2251 let tmp = tempfile::tempdir().unwrap();
2252 let app_dir = AppDir::new(tmp.path().to_path_buf());
2253 let evals_dir = app_dir.evals_dir();
2254 std::fs::create_dir_all(&evals_dir).unwrap();
2255
2256 let meta = r#"{"eval_id":"cot_1","strategy":"cot","timestamp":1}"#;
2258 std::fs::write(evals_dir.join("cot_1.json"), b"{}").unwrap();
2259 std::fs::write(evals_dir.join("cot_1.meta.json"), meta).unwrap();
2260
2261 let mut warnings: Vec<String> = Vec::new();
2262 let count = count_evals_for_pkg(&app_dir, "cot", &mut warnings);
2263 assert_eq!(count, 1, "should count 1 valid eval");
2264 assert!(warnings.is_empty(), "no warnings expected: {warnings:?}");
2265 }
2266
2267 fn write_cache_for_url(app_dir: &AppDir, url: &str, index: &HubIndex) {
2271 let dir = cache_dir(app_dir);
2272 std::fs::create_dir_all(&dir).unwrap();
2273 let path = dir.join(format!("{}.json", cache_key(url)));
2274 std::fs::write(&path, serde_json::to_string_pretty(index).unwrap()).unwrap();
2276 }
2277
2278 fn make_index(packages: Vec<(&str, &str)>) -> HubIndex {
2279 HubIndex {
2280 schema_version: "hub_index/v0".into(),
2281 updated_at: String::new(),
2282 packages: packages
2283 .into_iter()
2284 .map(|(name, version)| IndexEntry {
2285 entity: PkgEntity {
2286 name: name.to_string(),
2287 version: Some(version.to_string()),
2288 description: None,
2289 category: None,
2290 docstring: None,
2291 },
2292 source: PackageSource::Unknown,
2293 card_count: 0,
2294 best_card: None,
2295 })
2296 .collect(),
2297 }
2298 }
2299
2300 #[test]
2302 fn aggregate_index_empty_sources_returns_empty() {
2303 let tmp = tempfile::tempdir().unwrap();
2304 let app_dir = AppDir::new(tmp.path().to_path_buf());
2305 let (index, warnings) = {
2309 let mut w: Vec<String> = Vec::new();
2312 let urls = discover_index_urls(&app_dir, &mut w).unwrap();
2313 let mut packages: Vec<IndexEntry> = Vec::new();
2314 let mut seen: std::collections::HashSet<String> = std::collections::HashSet::new();
2315 for url in &urls {
2316 if let Ok(Some(idx)) = load_cached(&app_dir, url) {
2317 for e in idx.packages {
2318 if seen.insert(e.entity.name.clone()) {
2319 packages.push(e);
2320 }
2321 }
2322 }
2323 }
2324 (
2325 HubIndex {
2326 schema_version: "hub_index/v0".into(),
2327 updated_at: String::new(),
2328 packages,
2329 },
2330 w,
2331 )
2332 };
2333 assert!(
2334 index.packages.is_empty(),
2335 "no cached sources should produce empty packages"
2336 );
2337 assert!(warnings.is_empty(), "no warnings expected for cache misses");
2338 }
2339
2340 #[test]
2342 fn aggregate_index_one_source_returns_packages() {
2343 let tmp = tempfile::tempdir().unwrap();
2344 let app_dir = AppDir::new(tmp.path().to_path_buf());
2345 let url = "https://example.com/test_index.json";
2346 let source_index = make_index(vec![("cot", "0.1.0"), ("ucb", "0.2.0")]);
2347 write_cache_for_url(&app_dir, url, &source_index);
2348
2349 let reg_path = app_dir.hub_registries_json();
2351 std::fs::create_dir_all(reg_path.parent().unwrap()).unwrap();
2352 let reg_json = serde_json::json!({
2353 "registries": [{"source": url, "origin": "pkg_install", "added_at": "2026-01-01T00:00:00Z"}]
2354 });
2355 std::fs::write(®_path, reg_json.to_string()).unwrap();
2356
2357 let mut warnings: Vec<String> = Vec::new();
2358 let urls = discover_index_urls(&app_dir, &mut warnings).unwrap();
2359 let mut packages: Vec<IndexEntry> = Vec::new();
2360 let mut seen: std::collections::HashSet<String> = std::collections::HashSet::new();
2361 for u in &urls {
2362 if let Ok(Some(idx)) = load_cached(&app_dir, u) {
2363 for e in idx.packages {
2364 if seen.insert(e.entity.name.clone()) {
2365 packages.push(e);
2366 }
2367 }
2368 }
2369 }
2370
2371 assert!(
2372 packages.iter().any(|p| p.entity.name == "cot"),
2373 "cot expected"
2374 );
2375 assert!(
2376 packages.iter().any(|p| p.entity.name == "ucb"),
2377 "ucb expected"
2378 );
2379 }
2380
2381 #[test]
2383 fn aggregate_index_deduplicate_by_name_first_wins() {
2384 let tmp = tempfile::tempdir().unwrap();
2385 let app_dir = AppDir::new(tmp.path().to_path_buf());
2386 let url_a = "https://a.example.com/index.json";
2387 let url_b = "https://b.example.com/index.json";
2388
2389 let idx_a = make_index(vec![("cot", "1.0.0")]);
2391 let idx_b = make_index(vec![("cot", "2.0.0"), ("ucb", "0.1.0")]);
2392 write_cache_for_url(&app_dir, url_a, &idx_a);
2393 write_cache_for_url(&app_dir, url_b, &idx_b);
2394
2395 let reg_path = app_dir.hub_registries_json();
2396 std::fs::create_dir_all(reg_path.parent().unwrap()).unwrap();
2397 let reg_json = serde_json::json!({
2398 "registries": [
2399 {"source": url_a, "origin": "pkg_install", "added_at": "2026-01-01T00:00:00Z"},
2400 {"source": url_b, "origin": "pkg_install", "added_at": "2026-01-01T00:00:00Z"}
2401 ]
2402 });
2403 std::fs::write(®_path, reg_json.to_string()).unwrap();
2404
2405 let mut warnings: Vec<String> = Vec::new();
2406 let urls = {
2407 let mut raw = discover_index_urls(&app_dir, &mut warnings).unwrap();
2408 raw.retain(|u| u == url_a || u == url_b);
2410 raw
2411 };
2412
2413 let mut packages: Vec<IndexEntry> = Vec::new();
2414 let mut seen: std::collections::HashSet<String> = std::collections::HashSet::new();
2415 for u in &urls {
2416 if let Ok(Some(idx)) = load_cached(&app_dir, u) {
2417 for e in idx.packages {
2418 if seen.insert(e.entity.name.clone()) {
2419 packages.push(e);
2420 }
2421 }
2422 }
2423 }
2424
2425 let cot_count = packages.iter().filter(|p| p.entity.name == "cot").count();
2426 assert_eq!(cot_count, 1, "dedup: cot must appear exactly once");
2427 let ucb_count = packages.iter().filter(|p| p.entity.name == "ucb").count();
2428 assert_eq!(ucb_count, 1, "ucb from second source must appear");
2429 }
2430
2431 #[test]
2433 fn aggregate_index_corrupt_cache_collects_warning() {
2434 let tmp = tempfile::tempdir().unwrap();
2435 let app_dir = AppDir::new(tmp.path().to_path_buf());
2436 let url_corrupt = "https://corrupt.example.com/index.json";
2437
2438 let dir = cache_dir(&app_dir);
2440 std::fs::create_dir_all(&dir).unwrap();
2441 let path = dir.join(format!("{}.json", cache_key(url_corrupt)));
2442 std::fs::write(&path, b"{{{{ not valid json").unwrap();
2443
2444 let reg_path = app_dir.hub_registries_json();
2445 std::fs::create_dir_all(reg_path.parent().unwrap()).unwrap();
2446 let reg_json = serde_json::json!({
2447 "registries": [{"source": url_corrupt, "origin": "pkg_install", "added_at": "2026-01-01T00:00:00Z"}]
2448 });
2449 std::fs::write(®_path, reg_json.to_string()).unwrap();
2450
2451 let mut warnings: Vec<String> = Vec::new();
2452 let urls = discover_index_urls(&app_dir, &mut warnings).unwrap();
2453 let mut packages: Vec<IndexEntry> = Vec::new();
2454 let mut seen: std::collections::HashSet<String> = std::collections::HashSet::new();
2455 let mut extra_warnings: Vec<String> = Vec::new();
2456 for u in &urls {
2457 match load_cached(&app_dir, u) {
2458 Ok(Some(idx)) => {
2459 for e in idx.packages {
2460 if seen.insert(e.entity.name.clone()) {
2461 packages.push(e);
2462 }
2463 }
2464 }
2465 Ok(None) => {}
2466 Err(e) => extra_warnings.push(format!("hub cache read failed for {u}: {e}")),
2467 }
2468 }
2469
2470 assert!(
2471 !extra_warnings.is_empty(),
2472 "corrupt cache must produce a warning"
2473 );
2474 assert!(
2475 extra_warnings[0].contains("hub cache read failed"),
2476 "warning text mismatch: {}",
2477 extra_warnings[0]
2478 );
2479 assert!(packages.is_empty(), "no packages from corrupt source");
2480 }
2481
2482 #[tokio::test]
2485 async fn aggregate_index_registry_failure_returns_ok_with_warning() {
2486 let tmp = tempfile::tempdir().unwrap();
2487 let app_dir_root = tmp.path().to_path_buf();
2488
2489 let reg_path = AppDir::new(app_dir_root.clone()).hub_registries_json();
2491 std::fs::create_dir_all(reg_path.parent().unwrap()).unwrap();
2492 std::fs::write(®_path, b"{{{{ not valid json").unwrap();
2493
2494 let svc = super::super::test_support::make_app_service_at(app_dir_root).await;
2500 let result = AppService::aggregate_index(&svc);
2501 assert!(
2502 result.is_ok(),
2503 "aggregate_index must return Ok even on registry-load failure, got: {result:?}"
2504 );
2505 let (index, warnings) = result.unwrap();
2506 assert!(
2507 index.packages.is_empty(),
2508 "degraded response must have empty packages"
2509 );
2510 assert!(
2511 !warnings.is_empty(),
2512 "registry-load failure must produce a warning"
2513 );
2514 assert!(
2515 warnings
2516 .iter()
2517 .any(|w| w.contains("hub registry discovery failed")),
2518 "warning must mention registry discovery failure, got: {warnings:?}"
2519 );
2520 }
2521}