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
519fn load_cached(app_dir: &AppDir, url: &str) -> Result<Option<HubIndex>, String> {
527 let dir = cache_dir(app_dir);
528 let path = dir.join(format!("{}.json", cache_key(url)));
529 if !path.exists() {
530 return Ok(None);
531 }
532 let metadata = match std::fs::metadata(&path) {
534 Ok(m) => m,
535 Err(_) => return Ok(None),
536 };
537 let age = match metadata.modified().ok().and_then(|t| t.elapsed().ok()) {
538 Some(a) => a,
539 None => return Ok(None),
540 };
541 if age.as_secs() > CACHE_TTL_SECS {
542 return Ok(None);
543 }
544 let content = std::fs::read_to_string(&path)
548 .map_err(|e| format!("hub cache read {}: {e}", path.display()))?;
549 serde_json::from_str(&content)
550 .map(Some)
551 .map_err(|e| format!("hub cache parse {}: {e}", path.display()))
552}
553
554fn save_cached(app_dir: &AppDir, url: &str, index: &HubIndex) -> Result<(), String> {
561 let dir = cache_dir(app_dir);
562 std::fs::create_dir_all(&dir)
563 .map_err(|e| format!("failed to create hub cache dir {}: {e}", dir.display()))?;
564 let path = dir.join(format!("{}.json", cache_key(url)));
565 let json = serde_json::to_string_pretty(index)
566 .map_err(|e| format!("failed to serialize hub cache: {e}"))?;
567 std::fs::write(&path, json)
568 .map_err(|e| format!("failed to write hub cache {}: {e}", path.display()))
569}
570
571fn fetch_one(app_dir: &AppDir, url: &str) -> Result<(HubIndex, Option<String>), String> {
583 match load_cached(app_dir, url) {
585 Ok(Some(cached)) => return Ok((cached, None)),
586 Ok(None) => {} Err(e) => {
588 let warn = format!("hub cache corrupted for {url}: {e}; falling back to network");
592 return fetch_one_from_network(app_dir, url)
594 .map(|(idx, save_warn)| {
595 let combined = Some(match save_warn {
597 Some(sw) => format!("{warn}; {sw}"),
598 None => warn.clone(),
599 });
600 (idx, combined)
601 })
602 .map_err(|fetch_err| format!("{warn}; network fetch also failed: {fetch_err}"));
603 }
604 }
605
606 fetch_one_from_network(app_dir, url)
607}
608
609fn fetch_one_from_network(
613 app_dir: &AppDir,
614 url: &str,
615) -> Result<(HubIndex, Option<String>), String> {
616 let agent = ureq::Agent::new_with_config(
617 ureq::config::Config::builder()
618 .timeout_global(Some(HTTP_TIMEOUT))
619 .build(),
620 );
621 let body: String = agent
622 .get(url)
623 .call()
624 .map_err(|e| format!("Failed to fetch {url}: {e}"))?
625 .body_mut()
626 .read_to_string()
627 .map_err(|e| format!("Failed to read response from {url}: {e}"))?;
628
629 let index: HubIndex = serde_json::from_str(&body)
630 .map_err(|e| format!("Failed to parse index from {url}: {e}"))?;
631
632 let cache_warning = save_cached(app_dir, url, &index)
633 .err()
634 .map(|e| format!("hub cache write for {url}: {e}"));
635 Ok((index, cache_warning))
636}
637
638fn fetch_remote_indices(app_dir: &AppDir) -> Result<(HubIndex, Vec<String>), String> {
641 let mut warnings: Vec<String> = Vec::new();
642 let urls = discover_index_urls(app_dir, &mut warnings)?;
643 let mut all_packages: Vec<IndexEntry> = Vec::new();
644 let mut seen_names: HashSet<String> = HashSet::new();
645
646 for url in &urls {
647 match fetch_one(app_dir, url) {
648 Ok((index, cache_warning)) => {
649 for entry in index.packages {
650 if seen_names.insert(entry.entity.name.clone()) {
651 all_packages.push(entry);
652 }
653 }
655 if let Some(w) = cache_warning {
656 warnings.push(w);
657 }
658 }
659 Err(e) => {
660 warnings.push(e);
661 }
662 }
663 }
664
665 if all_packages.is_empty() && !warnings.is_empty() {
666 warnings.insert(
667 0,
668 "all remote indices unavailable, showing local packages only".to_string(),
669 );
670 }
671
672 let merged = HubIndex {
673 schema_version: "hub_index/v0".into(),
674 updated_at: String::new(),
675 packages: all_packages,
676 };
677 Ok((merged, warnings))
678}
679
680fn installed_packages(app_dir: &AppDir) -> Result<HashMap<String, Option<String>>, String> {
685 let mut map = HashMap::new();
686
687 let m = manifest::load_manifest(app_dir)?;
689 for (name, entry) in &m.packages {
690 map.insert(name.clone(), entry.version.clone());
691 }
692
693 let pkg_dir = app_dir.packages_dir();
695 if let Ok(entries) = std::fs::read_dir(&pkg_dir) {
696 for entry in entries.flatten() {
697 if entry.path().is_dir() {
698 if let Some(name) = entry.file_name().to_str() {
699 map.entry(name.to_string()).or_insert(None);
700 }
701 }
702 }
703 }
704
705 Ok(map)
706}
707
708fn local_card_counts(app_dir: &AppDir) -> HashMap<String, usize> {
710 let mut map = HashMap::new();
711 let cards_dir = app_dir.cards_dir();
712 let entries = match std::fs::read_dir(&cards_dir) {
713 Ok(e) => e,
714 Err(_) => return map,
715 };
716 for entry in entries.flatten() {
717 if !entry.path().is_dir() {
718 continue;
719 }
720 let pkg = match entry.file_name().to_str() {
721 Some(n) => n.to_string(),
722 None => continue,
723 };
724 let count = std::fs::read_dir(entry.path())
725 .map(|es| {
726 es.flatten()
727 .filter(|e| e.path().extension().is_some_and(|ext| ext == "toml"))
728 .count()
729 })
730 .unwrap_or(0);
731 if count > 0 {
732 map.insert(pkg, count);
733 }
734 }
735 map
736}
737
738fn count_evals_for_pkg(app_dir: &AppDir, pkg: &str, warnings: &mut Vec<String>) -> usize {
749 let evals_dir = app_dir.evals_dir();
750 let entries = match std::fs::read_dir(&evals_dir) {
751 Ok(e) => e,
752 Err(_) => return 0,
753 };
754
755 let mut meta_stems: HashSet<String> = HashSet::new();
758 let mut meta_matches: usize = 0;
759 let mut non_meta_paths: Vec<(PathBuf, String)> = Vec::new(); for entry in entries.flatten() {
762 let path = entry.path();
763 let name = match path.file_name().and_then(|n| n.to_str()) {
764 Some(n) => n.to_string(),
765 None => continue,
766 };
767
768 if name.ends_with(".meta.json") {
769 let stem = name.trim_end_matches(".meta.json").to_string();
770 meta_stems.insert(stem.clone());
771 match std::fs::read_to_string(&path) {
773 Ok(content) => match serde_json::from_str::<serde_json::Value>(&content) {
774 Ok(val) => {
775 if val.get("strategy").and_then(|s| s.as_str()) == Some(pkg) {
776 meta_matches += 1;
777 }
778 }
779 Err(e) => warnings.push(format!("eval meta parse {}: {e}", path.display())),
780 },
781 Err(e) => warnings.push(format!("eval meta read {}: {e}", path.display())),
782 }
783 continue;
784 }
785
786 if !name.ends_with(".json") || name.starts_with("compare_") {
788 continue;
789 }
790
791 let stem = path
792 .file_stem()
793 .and_then(|s| s.to_str())
794 .unwrap_or("")
795 .to_string();
796 non_meta_paths.push((path, stem));
797 }
798
799 let mut fallback_matches: usize = 0;
802 for (path, stem) in &non_meta_paths {
803 if meta_stems.contains(stem) {
804 continue;
805 }
806 match std::fs::read_to_string(path) {
807 Ok(c) => match serde_json::from_str::<serde_json::Value>(&c) {
808 Ok(v) => {
809 if v.get("strategy").and_then(|s| s.as_str()) == Some(pkg) {
810 fallback_matches += 1;
811 }
812 }
813 Err(e) => warnings.push(format!("eval result parse {}: {e}", path.display())),
814 },
815 Err(e) => warnings.push(format!("eval result read {}: {e}", path.display())),
816 }
817 }
818
819 meta_matches + fallback_matches
820}
821
822fn merge(app_dir: &AppDir, remote: &HubIndex) -> Result<Vec<SearchResult>, String> {
830 let installed = installed_packages(app_dir)?;
831 let card_counts = local_card_counts(app_dir);
832 let pkg_dir: Option<PathBuf> = Some(app_dir.packages_dir());
833
834 let mut seen: HashSet<String> = HashSet::new();
835 let mut results: Vec<SearchResult> = Vec::new();
836
837 for entry in &remote.packages {
838 let pkg_name = &entry.entity.name;
839 let is_installed = installed.contains_key(pkg_name);
840 let local_cards = card_counts.get(pkg_name).copied().unwrap_or(0);
841
842 let docstring = if entry.entity.docstring.as_deref().unwrap_or("").is_empty()
846 && is_installed
847 {
848 pkg_dir
849 .as_ref()
850 .and_then(|d| PkgEntity::parse_from_init_lua(&d.join(pkg_name).join("init.lua")))
851 .and_then(|e| e.docstring)
852 } else {
853 entry.entity.docstring.clone()
854 };
855
856 seen.insert(pkg_name.clone());
857 let mut merged_entity = entry.entity.clone();
858 merged_entity.docstring = docstring;
859 results.push(SearchResult {
860 entity: merged_entity,
861 source: entry.source.clone(),
862 installed: is_installed,
863 card_count: if is_installed && local_cards > entry.card_count {
864 local_cards
865 } else {
866 entry.card_count
867 },
868 best_card: entry.best_card.clone(),
869 docstring_matched: None,
870 });
871 }
872
873 for (name, version) in &installed {
875 if seen.contains(name) {
876 continue;
877 }
878 let parsed_entity = pkg_dir
885 .as_ref()
886 .and_then(|d| PkgEntity::parse_from_init_lua(&d.join(name).join("init.lua")));
887 let entity = parsed_entity.unwrap_or(PkgEntity {
888 name: name.clone(),
889 version: version.clone(),
890 description: None,
891 category: None,
892 docstring: None,
893 });
894 results.push(SearchResult {
895 entity,
896 source: PackageSource::Unknown,
897 installed: true,
898 card_count: card_counts.get(name).copied().unwrap_or(0),
899 best_card: None,
900 docstring_matched: None,
901 });
902 }
903
904 Ok(results)
905}
906
907fn matches_query(result: &SearchResult, query: &str) -> bool {
910 let q = query.to_lowercase();
911 let pkg = &result.entity;
912 let empty = String::new();
913 pkg.name.to_lowercase().contains(&q)
914 || pkg
915 .description
916 .as_ref()
917 .unwrap_or(&empty)
918 .to_lowercase()
919 .contains(&q)
920 || pkg
921 .category
922 .as_ref()
923 .unwrap_or(&empty)
924 .to_lowercase()
925 .contains(&q)
926 || pkg
927 .docstring
928 .as_ref()
929 .unwrap_or(&empty)
930 .to_lowercase()
931 .contains(&q)
932}
933
934fn build_index(app_dir: &AppDir, source_dir: Option<&std::path::Path>) -> Result<HubIndex, String> {
951 let empty = || HubIndex {
952 schema_version: "hub_index/v0".into(),
953 updated_at: super::manifest::now_iso8601(),
954 packages: Vec::new(),
955 };
956
957 let pkg_dir = match source_dir {
958 Some(d) => d.to_path_buf(),
959 None => app_dir.packages_dir(),
960 };
961
962 let use_local_state = source_dir.is_none();
963 let card_counts = if use_local_state {
964 local_card_counts(app_dir)
965 } else {
966 HashMap::new()
967 };
968 let manifest = if use_local_state {
975 manifest::load_manifest(app_dir)?
976 } else {
977 manifest::Manifest::default()
978 };
979
980 let mut entries = Vec::new();
981
982 let dir_entries = match std::fs::read_dir(&pkg_dir) {
986 Ok(e) => e,
987 Err(_) => return Ok(empty()),
988 };
989
990 for entry in dir_entries.flatten() {
991 if !entry.path().is_dir() {
992 continue;
993 }
994 let dir_name = match entry.file_name().to_str() {
995 Some(n) if !n.starts_with('.') && !n.starts_with('_') => n.to_string(),
996 _ => continue,
997 };
998
999 let init_lua = entry.path().join("init.lua");
1000 if !init_lua.exists() {
1001 continue;
1002 }
1003
1004 let Some(entity) = PkgEntity::parse_from_init_lua(&init_lua) else {
1011 continue;
1012 };
1013
1014 let source = manifest
1018 .packages
1019 .get(&dir_name)
1020 .map(|e| e.source.clone())
1021 .unwrap_or_default();
1022
1023 entries.push(IndexEntry {
1024 entity,
1025 source,
1026 card_count: card_counts.get(&dir_name).copied().unwrap_or(0),
1027 best_card: None,
1028 });
1029 }
1030
1031 entries.sort_by(|a, b| a.entity.name.cmp(&b.entity.name));
1032
1033 Ok(HubIndex {
1034 schema_version: "hub_index/v0".into(),
1035 updated_at: super::manifest::now_iso8601(),
1036 packages: entries,
1037 })
1038}
1039
1040impl AppService {
1043 pub fn hub_reindex(
1052 &self,
1053 output_path: Option<&str>,
1054 source_dir: Option<&str>,
1055 ) -> Result<String, String> {
1056 let src = source_dir.map(std::path::Path::new);
1057 if let Some(d) = src {
1058 if !d.is_dir() {
1059 return Err(format!("source_dir '{}' is not a directory", d.display()));
1060 }
1061 }
1062 let app_dir = self.log_config.app_dir();
1063 let index = build_index(&app_dir, src)?;
1064
1065 let written_path = if let Some(path) = output_path {
1066 let json = serde_json::to_string_pretty(&index)
1067 .map_err(|e| format!("Failed to serialize index: {e}"))?;
1068 std::fs::write(path, &json)
1069 .map_err(|e| format!("Failed to write index to {path}: {e}"))?;
1070 Some(path.to_string())
1071 } else {
1072 None
1073 };
1074
1075 let response = serde_json::json!({
1076 "package_count": index.packages.len(),
1077 "updated_at": index.updated_at,
1078 "output_path": written_path,
1079 "source_dir": source_dir,
1080 });
1081 Ok(response.to_string())
1082 }
1083
1084 pub fn hub_info(&self, pkg: &str) -> Result<String, String> {
1089 use algocline_engine::card;
1090
1091 if pkg.contains("..") || pkg.contains('/') || pkg.contains('\\') {
1093 return Err(format!("Invalid package name: '{pkg}'"));
1094 }
1095
1096 let app_dir = self.log_config.app_dir();
1098 let installed = installed_packages(&app_dir)?;
1099 let is_installed = installed.contains_key(pkg);
1100
1101 let (version, description, category, source) = {
1107 let (remote, _) = fetch_remote_indices(&app_dir)?;
1108 if let Some(entry) = remote.packages.iter().find(|e| e.entity.name == pkg) {
1109 (
1110 entry.entity.version.clone().unwrap_or_default(),
1111 entry.entity.description.clone().unwrap_or_default(),
1112 entry.entity.category.clone().unwrap_or_default(),
1113 entry.source.clone(),
1114 )
1115 } else if is_installed {
1116 let init_lua = app_dir.packages_dir().join(pkg).join("init.lua");
1122 let entity = PkgEntity::parse_from_init_lua(&init_lua);
1123 let manifest_source = manifest::load_manifest(&app_dir)?
1124 .packages
1125 .get(pkg)
1126 .map(|e| e.source.clone())
1127 .unwrap_or_default();
1128 match entity {
1129 Some(e) => (
1130 e.version.unwrap_or_default(),
1131 e.description.unwrap_or_default(),
1132 e.category.unwrap_or_default(),
1133 manifest_source,
1134 ),
1135 None => (
1136 installed.get(pkg).cloned().flatten().unwrap_or_default(),
1137 String::new(),
1138 String::new(),
1139 manifest_source,
1140 ),
1141 }
1142 } else {
1143 return Err(format!(
1144 "Package '{pkg}' not found in remote indices or locally installed packages"
1145 ));
1146 }
1147 };
1148
1149 let mut warnings: Vec<String> = Vec::new();
1153
1154 let card_rows = match self.card_store.list(Some(pkg)) {
1156 Ok(rows) => rows,
1157 Err(e) => {
1158 let msg = format!("card store list for '{pkg}': {e}");
1159 tracing::warn!("{}", msg);
1160 warnings.push(msg);
1161 vec![]
1162 }
1163 };
1164 let cards_json = card::summaries_to_json(&card_rows);
1165
1166 let aliases_json = match self.card_store.alias_list(Some(pkg)) {
1168 Ok(rows) => card::aliases_to_json(&rows),
1169 Err(e) => {
1170 let msg = format!("card store alias_list for '{pkg}': {e}");
1171 tracing::warn!("{}", msg);
1172 warnings.push(msg);
1173 serde_json::json!([])
1174 }
1175 };
1176
1177 let card_count = card_rows.len();
1179 let best_pass_rate = card_rows
1180 .iter()
1181 .filter_map(|c| c.pass_rate)
1182 .fold(f64::NEG_INFINITY, f64::max);
1183 let best_pass_rate = if best_pass_rate.is_finite() {
1184 Some(best_pass_rate)
1185 } else {
1186 None
1187 };
1188
1189 let eval_count = count_evals_for_pkg(&app_dir, pkg, &mut warnings);
1191
1192 let mut response = serde_json::json!({
1193 "pkg": {
1194 "name": pkg,
1195 "version": version,
1196 "description": description,
1197 "category": category,
1198 "source": source,
1199 "installed": is_installed,
1200 },
1201 "cards": cards_json,
1202 "aliases": aliases_json,
1203 "stats": {
1204 "card_count": card_count,
1205 "eval_count": eval_count,
1206 "best_pass_rate": best_pass_rate,
1207 },
1208 });
1209 if !warnings.is_empty() {
1210 response["warnings"] = serde_json::json!(warnings);
1211 }
1212 Ok(response.to_string())
1213 }
1214
1215 pub(crate) fn hub_search(
1247 &self,
1248 query: Option<&str>,
1249 category: Option<&str>,
1250 installed_only: Option<bool>,
1251 opts: ListOpts,
1252 ) -> Result<String, String> {
1253 let app_dir = self.log_config.app_dir();
1254 let (remote, warnings) = fetch_remote_indices(&app_dir)?;
1255 let mut results = merge(&app_dir, &remote)?;
1256
1257 let query_lower = query.filter(|q| !q.is_empty()).map(|q| q.to_lowercase());
1260 if let Some(ref ql) = query_lower {
1261 results.retain(|r| matches_query(r, ql));
1262 }
1263
1264 if let Some(ref ql) = query_lower {
1268 for r in &mut results {
1269 let empty = String::new();
1270 let pkg = &r.entity;
1271 let other_hit = pkg.name.to_lowercase().contains(ql)
1272 || pkg
1273 .description
1274 .as_ref()
1275 .unwrap_or(&empty)
1276 .to_lowercase()
1277 .contains(ql)
1278 || pkg
1279 .category
1280 .as_ref()
1281 .unwrap_or(&empty)
1282 .to_lowercase()
1283 .contains(ql);
1284 let doc_hit = pkg
1285 .docstring
1286 .as_ref()
1287 .unwrap_or(&empty)
1288 .to_lowercase()
1289 .contains(ql);
1290 r.docstring_matched = if !other_hit && doc_hit {
1291 Some(true)
1292 } else {
1293 None
1294 };
1295 }
1296 }
1297
1298 let mut filter_map: std::collections::HashMap<String, serde_json::Value> =
1302 opts.filter.unwrap_or_default();
1303 if let Some(cat) = category {
1304 filter_map
1305 .entry("category".to_string())
1306 .or_insert_with(|| serde_json::Value::String(cat.to_string()));
1307 }
1308 if let Some(only) = installed_only {
1309 if only {
1313 filter_map
1314 .entry("installed".to_string())
1315 .or_insert(serde_json::Value::Bool(true));
1316 }
1317 }
1318
1319 let sort_str = opts.sort.as_deref().unwrap_or("-installed,name");
1322 let sort_keys = parse_sort(sort_str)?;
1323
1324 let fields = resolve_fields(
1327 opts.verbose.as_deref(),
1328 opts.fields.as_deref(),
1329 HUB_SEARCH_SUMMARY,
1330 HUB_SEARCH_FULL,
1331 )?;
1332 let include_docstring = fields.iter().any(|f| f == "docstring");
1333
1334 let mut items: Vec<serde_json::Value> = results
1337 .iter()
1338 .map(|r| r.to_value_with_optional_docstring(include_docstring))
1339 .collect();
1340
1341 if !filter_map.is_empty() {
1344 items.retain(|v| matches_filter(v, &filter_map));
1345 }
1346
1347 apply_sort_by_value(&mut items, &sort_keys);
1349
1350 let total = items.len();
1354 let limit = opts.limit.unwrap_or(50);
1355 if limit > 0 {
1356 items.truncate(limit);
1357 }
1358
1359 let projected: Vec<serde_json::Value> = items
1362 .into_iter()
1363 .map(|v| project_fields(v, &fields))
1364 .collect();
1365
1366 let mut _src_warnings: Vec<String> = Vec::new();
1371 let sources = discover_index_urls(&app_dir, &mut _src_warnings)?;
1372
1373 let mut json = serde_json::json!({
1374 "results": projected,
1375 "total": total,
1376 "sources": sources,
1377 });
1378 if !warnings.is_empty() {
1379 json["warnings"] = serde_json::json!(warnings);
1380 }
1381 Ok(json.to_string())
1382 }
1383}
1384
1385#[cfg(test)]
1386mod tests {
1387 use super::*;
1388
1389 #[test]
1390 fn repo_to_index_url_github() {
1391 assert_eq!(
1392 repo_to_index_url("https://github.com/ynishi/algocline-bundled-packages"),
1393 Some(
1394 "https://raw.githubusercontent.com/ynishi/algocline-bundled-packages/main/hub_index.json"
1395 .to_string()
1396 )
1397 );
1398 }
1399
1400 #[test]
1401 fn repo_to_index_url_github_trailing_slash() {
1402 assert_eq!(
1403 repo_to_index_url("https://github.com/user/repo/"),
1404 Some("https://raw.githubusercontent.com/user/repo/main/hub_index.json".to_string())
1405 );
1406 }
1407
1408 #[test]
1409 fn repo_to_index_url_github_dot_git() {
1410 assert_eq!(
1411 repo_to_index_url("https://github.com/user/repo.git"),
1412 Some("https://raw.githubusercontent.com/user/repo/main/hub_index.json".to_string())
1413 );
1414 }
1415
1416 #[test]
1417 fn repo_to_index_url_direct_json() {
1418 assert_eq!(
1419 repo_to_index_url("https://example.com/my_index.json"),
1420 Some("https://example.com/my_index.json".to_string())
1421 );
1422 }
1423
1424 #[test]
1425 fn repo_to_index_url_unknown_host_no_json() {
1426 assert_eq!(repo_to_index_url("https://example.com/some-repo"), None);
1427 }
1428
1429 #[test]
1430 fn repo_to_index_url_local_path() {
1431 assert_eq!(repo_to_index_url("/home/user/my-pkg"), None);
1432 }
1433
1434 #[test]
1435 fn cache_key_stable() {
1436 let k1 = cache_key("https://example.com/index.json");
1437 let k2 = cache_key("https://example.com/index.json");
1438 assert_eq!(k1, k2);
1439 assert_eq!(k1.len(), 16); }
1441
1442 #[test]
1443 fn cache_key_different_urls() {
1444 let k1 = cache_key("https://a.com/index.json");
1445 let k2 = cache_key("https://b.com/index.json");
1446 assert_ne!(k1, k2);
1447 }
1448
1449 #[test]
1455 fn merge_dedup_uses_hashset() {
1456 let tmp = tempfile::tempdir().unwrap();
1459 let app_dir = AppDir::new(tmp.path().to_path_buf());
1460 let remote = HubIndex {
1461 schema_version: "hub_index/v0".into(),
1462 updated_at: String::new(),
1463 packages: vec![IndexEntry {
1464 entity: PkgEntity {
1465 name: "remote_only".into(),
1466 version: Some("1.0".into()),
1467 description: Some("from remote".into()),
1468 category: Some("test".into()),
1469 docstring: None,
1470 },
1471 source: PackageSource::Unknown,
1472 card_count: 0,
1473 best_card: None,
1474 }],
1475 };
1476
1477 let results = merge(&app_dir, &remote).expect("merge over empty app_dir should succeed");
1478 assert!(results.iter().any(|r| r.entity.name == "remote_only"));
1480 }
1481
1482 #[test]
1483 fn matches_query_searches_docstring() {
1484 let result = SearchResult {
1485 entity: PkgEntity {
1486 name: "cascade".into(),
1487 version: Some("0.1.0".into()),
1488 description: Some("Multi-level routing".into()),
1489 category: Some("meta".into()),
1490 docstring: Some("Based on FrugalGPT. Uses Thompson Sampling.".into()),
1491 },
1492 source: PackageSource::Unknown,
1493 installed: true,
1494 card_count: 0,
1495 best_card: None,
1496 docstring_matched: None,
1497 };
1498
1499 assert!(matches_query(&result, "thompson"), "docstring match");
1500 assert!(matches_query(&result, "FrugalGPT"), "docstring match case");
1501 assert!(matches_query(&result, "routing"), "description match");
1502 assert!(!matches_query(&result, "bayesian"), "no match");
1503 }
1504
1505 fn sample_search_result() -> SearchResult {
1514 SearchResult {
1515 entity: PkgEntity {
1516 name: "cascade".into(),
1517 version: Some("0.1.0".into()),
1518 description: Some("Multi-level routing".into()),
1519 category: Some("reasoning".into()),
1520 docstring: Some("Based on FrugalGPT. Uses Thompson Sampling.".into()),
1521 },
1522 source: PackageSource::Git {
1523 url: "https://example.com/cascade".into(),
1524 rev: None,
1525 },
1526 installed: true,
1527 card_count: 3,
1528 best_card: None,
1529 docstring_matched: None,
1530 }
1531 }
1532
1533 #[test]
1534 fn to_value_default_omits_docstring() {
1535 let r = sample_search_result();
1536 let v = r.to_value_with_optional_docstring(false);
1537 let obj = v.as_object().expect("object");
1538 assert!(
1539 !obj.contains_key("docstring"),
1540 "default summary must not leak docstring"
1541 );
1542 assert_eq!(obj.get("name").and_then(|x| x.as_str()), Some("cascade"));
1543 assert!(
1546 !obj.contains_key("docstring_matched"),
1547 "docstring_matched=None must be omitted"
1548 );
1549 }
1550
1551 #[test]
1552 fn to_value_include_reattaches_docstring() {
1553 let r = sample_search_result();
1554 let v = r.to_value_with_optional_docstring(true);
1555 let obj = v.as_object().expect("object");
1556 assert_eq!(
1557 obj.get("docstring").and_then(|x| x.as_str()),
1558 Some("Based on FrugalGPT. Uses Thompson Sampling.")
1559 );
1560 }
1561
1562 #[test]
1563 fn to_value_serializes_docstring_matched_when_set() {
1564 let mut r = sample_search_result();
1565 r.docstring_matched = Some(true);
1566 let v = r.to_value_with_optional_docstring(false);
1567 let obj = v.as_object().expect("object");
1568 assert_eq!(
1569 obj.get("docstring_matched").and_then(|x| x.as_bool()),
1570 Some(true)
1571 );
1572 }
1573
1574 #[test]
1584 fn hub_search_default_summary_excludes_docstring() {
1585 let r = sample_search_result();
1586 let fields = resolve_fields(None, None, HUB_SEARCH_SUMMARY, HUB_SEARCH_FULL).unwrap();
1587 let include_docstring = fields.iter().any(|f| f == "docstring");
1588 let v = project_fields(
1589 r.to_value_with_optional_docstring(include_docstring),
1590 &fields,
1591 );
1592 let obj = v.as_object().expect("object");
1593 assert!(
1594 !obj.contains_key("docstring"),
1595 "summary preset must omit docstring"
1596 );
1597 for key in ["name", "version", "description", "category", "installed"] {
1599 assert!(obj.contains_key(key), "summary preset key {key} missing");
1600 }
1601 }
1602
1603 #[test]
1604 fn hub_search_verbose_full_includes_docstring() {
1605 let r = sample_search_result();
1606 let fields =
1607 resolve_fields(Some("full"), None, HUB_SEARCH_SUMMARY, HUB_SEARCH_FULL).unwrap();
1608 let include_docstring = fields.iter().any(|f| f == "docstring");
1609 let v = project_fields(
1610 r.to_value_with_optional_docstring(include_docstring),
1611 &fields,
1612 );
1613 let obj = v.as_object().expect("object");
1614 assert_eq!(
1615 obj.get("docstring").and_then(|x| x.as_str()),
1616 Some("Based on FrugalGPT. Uses Thompson Sampling.")
1617 );
1618 for key in ["source", "card_count"] {
1620 assert!(obj.contains_key(key), "full preset key {key} missing");
1621 }
1622 }
1623
1624 #[test]
1625 fn hub_search_fields_beats_verbose() {
1626 let r = sample_search_result();
1627 let explicit = vec!["name".to_string(), "docstring".to_string()];
1628 let fields = resolve_fields(
1631 Some("summary"),
1632 Some(&explicit),
1633 HUB_SEARCH_SUMMARY,
1634 HUB_SEARCH_FULL,
1635 )
1636 .unwrap();
1637 let include_docstring = fields.iter().any(|f| f == "docstring");
1638 let v = project_fields(
1639 r.to_value_with_optional_docstring(include_docstring),
1640 &fields,
1641 );
1642 let obj = v.as_object().expect("object");
1643 assert_eq!(obj.len(), 2, "only the two requested fields");
1644 assert!(obj.contains_key("name"));
1645 assert!(obj.contains_key("docstring"));
1646 }
1647
1648 #[test]
1649 fn hub_search_fields_unknown_key_silently_skipped() {
1650 let r = sample_search_result();
1651 let explicit = vec!["name".to_string(), "bogus".to_string()];
1652 let fields =
1653 resolve_fields(None, Some(&explicit), HUB_SEARCH_SUMMARY, HUB_SEARCH_FULL).unwrap();
1654 let v = project_fields(r.to_value_with_optional_docstring(false), &fields);
1655 let obj = v.as_object().expect("object");
1656 assert_eq!(obj.len(), 1, "bogus must not appear");
1657 assert!(obj.contains_key("name"));
1658 }
1659
1660 #[test]
1661 fn hub_search_invalid_verbose_errors() {
1662 let err =
1663 resolve_fields(Some("fat"), None, HUB_SEARCH_SUMMARY, HUB_SEARCH_FULL).unwrap_err();
1664 assert!(
1665 err.contains("fat"),
1666 "error must mention the offending value"
1667 );
1668 }
1669
1670 fn classify(r: &SearchResult, query: &str) -> Option<bool> {
1679 let ql = query.to_lowercase();
1680 if query.is_empty() {
1681 return None;
1682 }
1683 let empty = String::new();
1684 let pkg = &r.entity;
1685 let other_hit = pkg.name.to_lowercase().contains(&ql)
1686 || pkg
1687 .description
1688 .as_ref()
1689 .unwrap_or(&empty)
1690 .to_lowercase()
1691 .contains(&ql)
1692 || pkg
1693 .category
1694 .as_ref()
1695 .unwrap_or(&empty)
1696 .to_lowercase()
1697 .contains(&ql);
1698 let doc_hit = pkg
1699 .docstring
1700 .as_ref()
1701 .unwrap_or(&empty)
1702 .to_lowercase()
1703 .contains(&ql);
1704 if !other_hit && doc_hit {
1705 Some(true)
1706 } else {
1707 None
1708 }
1709 }
1710
1711 #[test]
1712 fn docstring_matched_true_when_only_docstring_hits() {
1713 let r = sample_search_result();
1714 assert_eq!(classify(&r, "thompson"), Some(true));
1716 }
1717
1718 #[test]
1719 fn docstring_matched_none_when_name_also_hits() {
1720 let r = sample_search_result();
1721 assert_eq!(classify(&r, "cascade"), None);
1723 }
1724
1725 #[test]
1726 fn docstring_matched_none_when_description_hits() {
1727 let r = sample_search_result();
1728 assert_eq!(classify(&r, "routing"), None);
1730 }
1731
1732 #[test]
1733 fn docstring_matched_none_when_query_empty() {
1734 let r = sample_search_result();
1735 assert_eq!(classify(&r, ""), None);
1736 }
1737
1738 fn build_filter_map(
1746 category: Option<&str>,
1747 installed_only: Option<bool>,
1748 explicit: Option<HashMap<String, serde_json::Value>>,
1749 ) -> HashMap<String, serde_json::Value> {
1750 let mut filter_map = explicit.unwrap_or_default();
1751 if let Some(cat) = category {
1752 filter_map
1753 .entry("category".to_string())
1754 .or_insert_with(|| serde_json::Value::String(cat.to_string()));
1755 }
1756 if let Some(only) = installed_only {
1757 if only {
1758 filter_map
1759 .entry("installed".to_string())
1760 .or_insert(serde_json::Value::Bool(true));
1761 }
1762 }
1763 filter_map
1764 }
1765
1766 #[test]
1767 fn filter_by_category_via_legacy_param() {
1768 let m = build_filter_map(Some("reasoning"), None, None);
1769 assert_eq!(
1770 m.get("category"),
1771 Some(&serde_json::Value::String("reasoning".to_string()))
1772 );
1773 }
1774
1775 #[test]
1776 fn filter_by_installed_only_via_legacy_param() {
1777 let m = build_filter_map(None, Some(true), None);
1778 assert_eq!(m.get("installed"), Some(&serde_json::Value::Bool(true)));
1779 }
1780
1781 #[test]
1782 fn filter_installed_only_false_is_noop() {
1783 let m = build_filter_map(None, Some(false), None);
1784 assert!(
1785 !m.contains_key("installed"),
1786 "installed_only=false should not fold in"
1787 );
1788 }
1789
1790 #[test]
1791 fn filter_beats_legacy_param_on_conflict() {
1792 let mut explicit = HashMap::new();
1795 explicit.insert(
1796 "category".to_string(),
1797 serde_json::Value::String("meta".to_string()),
1798 );
1799 let m = build_filter_map(Some("reasoning"), None, Some(explicit));
1800 assert_eq!(
1801 m.get("category"),
1802 Some(&serde_json::Value::String("meta".to_string()))
1803 );
1804 }
1805
1806 #[test]
1807 fn filter_merges_legacy_when_no_conflict() {
1808 let mut explicit = HashMap::new();
1811 explicit.insert("installed".to_string(), serde_json::Value::Bool(true));
1812 let m = build_filter_map(Some("reasoning"), None, Some(explicit));
1813 assert_eq!(
1814 m.get("category"),
1815 Some(&serde_json::Value::String("reasoning".to_string()))
1816 );
1817 assert_eq!(m.get("installed"), Some(&serde_json::Value::Bool(true)));
1818 }
1819
1820 #[test]
1823 fn load_registries_missing_file_returns_default() {
1824 let tmp = tempfile::tempdir().unwrap();
1825 let app_dir = AppDir::new(tmp.path().to_path_buf());
1826 let result = load_registries(&app_dir);
1828 assert!(result.is_ok(), "missing file should be Ok: {result:?}");
1829 assert!(result.unwrap().registries.is_empty());
1830 }
1831
1832 #[test]
1833 fn load_registries_corrupt_json_returns_err() {
1834 let tmp = tempfile::tempdir().unwrap();
1835 let app_dir = AppDir::new(tmp.path().to_path_buf());
1836 let path = app_dir.hub_registries_json();
1838 std::fs::create_dir_all(path.parent().unwrap()).unwrap();
1839 std::fs::write(&path, b"not valid json {{{").unwrap();
1840 let result = load_registries(&app_dir);
1841 assert!(result.is_err(), "corrupt JSON must propagate Err");
1842 let msg = result.unwrap_err().to_string();
1843 assert!(
1844 msg.contains("parse"),
1845 "error message should mention parse: {msg}"
1846 );
1847 }
1848
1849 #[test]
1850 fn load_registries_valid_file_deserializes() {
1851 let tmp = tempfile::tempdir().unwrap();
1852 let app_dir = AppDir::new(tmp.path().to_path_buf());
1853 let path = app_dir.hub_registries_json();
1854 std::fs::create_dir_all(path.parent().unwrap()).unwrap();
1855 let content = r#"{"registries":[{"source":"https://github.com/user/repo","origin":"pkg_install","added_at":"2026-01-01T00:00:00Z"}]}"#;
1856 std::fs::write(&path, content).unwrap();
1857 let result = load_registries(&app_dir);
1858 assert!(result.is_ok(), "valid JSON must parse Ok: {result:?}");
1859 let reg = result.unwrap();
1860 assert_eq!(reg.registries.len(), 1);
1861 assert_eq!(reg.registries[0].source, "https://github.com/user/repo");
1862 }
1863
1864 #[test]
1867 fn default_sort_is_minus_installed_name() {
1868 let keys = parse_sort("-installed,name").unwrap();
1869 assert_eq!(keys.len(), 2);
1870 assert_eq!(keys[0].key, "installed");
1871 assert!(keys[0].desc, "installed must sort desc (true first)");
1872 assert_eq!(keys[1].key, "name");
1873 assert!(!keys[1].desc);
1874
1875 let mut items = vec![
1877 serde_json::json!({"installed": false, "name": "zeta"}),
1878 serde_json::json!({"installed": true, "name": "mu"}),
1879 serde_json::json!({"installed": false, "name": "alpha"}),
1880 serde_json::json!({"installed": true, "name": "beta"}),
1881 ];
1882 apply_sort_by_value(&mut items, &keys);
1883 let names: Vec<&str> = items
1884 .iter()
1885 .map(|v| v.get("name").and_then(|x| x.as_str()).unwrap_or(""))
1886 .collect();
1887 assert_eq!(names, vec!["beta", "mu", "alpha", "zeta"]);
1888 }
1889
1890 #[test]
1895 fn collection_url_from_config_absent_returns_ok_none() {
1896 let tmp = tempfile::tempdir().unwrap();
1897 let app_dir = AppDir::new(tmp.path().to_path_buf());
1898 let result = collection_url_from_config(&app_dir);
1900 assert!(
1901 matches!(result, Ok(None)),
1902 "absent config.toml must return Ok(None), got {result:?}"
1903 );
1904 }
1905
1906 #[test]
1907 fn collection_url_from_config_corrupt_toml_returns_err() {
1908 let tmp = tempfile::tempdir().unwrap();
1909 let app_dir = AppDir::new(tmp.path().to_path_buf());
1910 let path = app_dir.config_toml();
1911 std::fs::create_dir_all(path.parent().unwrap()).unwrap();
1912 std::fs::write(&path, b"[hub\ncollection_url = broken{{{{").unwrap();
1913 let result = collection_url_from_config(&app_dir);
1914 assert!(
1915 result.is_err(),
1916 "corrupt TOML must return Err, got {result:?}"
1917 );
1918 }
1919
1920 #[test]
1921 fn collection_url_from_config_valid_returns_url() {
1922 let tmp = tempfile::tempdir().unwrap();
1923 let app_dir = AppDir::new(tmp.path().to_path_buf());
1924 let path = app_dir.config_toml();
1925 std::fs::create_dir_all(path.parent().unwrap()).unwrap();
1926 std::fs::write(
1927 &path,
1928 b"[hub]\ncollection_url = \"https://example.com/hub_index.json\"\n",
1929 )
1930 .unwrap();
1931 let result = collection_url_from_config(&app_dir);
1932 assert_eq!(
1933 result.unwrap(),
1934 Some("https://example.com/hub_index.json".to_string())
1935 );
1936 }
1937
1938 #[test]
1939 fn collection_url_from_config_no_hub_section_returns_none() {
1940 let tmp = tempfile::tempdir().unwrap();
1941 let app_dir = AppDir::new(tmp.path().to_path_buf());
1942 let path = app_dir.config_toml();
1943 std::fs::create_dir_all(path.parent().unwrap()).unwrap();
1944 std::fs::write(&path, b"[some_other_section]\nfoo = \"bar\"\n").unwrap();
1945 let result = collection_url_from_config(&app_dir);
1946 assert!(
1947 matches!(result, Ok(None)),
1948 "config without [hub] must return Ok(None), got {result:?}"
1949 );
1950 }
1951
1952 #[test]
1955 fn load_cached_absent_returns_ok_none() {
1956 let tmp = tempfile::tempdir().unwrap();
1957 let app_dir = AppDir::new(tmp.path().to_path_buf());
1958 let result = load_cached(&app_dir, "https://example.com/index.json");
1959 assert!(
1960 matches!(result, Ok(None)),
1961 "absent cache file must return Ok(None), got {result:?}"
1962 );
1963 }
1964
1965 #[test]
1966 fn load_cached_corrupt_json_within_ttl_returns_err() {
1967 let tmp = tempfile::tempdir().unwrap();
1968 let app_dir = AppDir::new(tmp.path().to_path_buf());
1969 let url = "https://example.com/index.json";
1970 let dir = cache_dir(&app_dir);
1971 std::fs::create_dir_all(&dir).unwrap();
1972 let path = dir.join(format!("{}.json", cache_key(url)));
1973 std::fs::write(&path, b"not valid json {{{{").unwrap();
1974 let result = load_cached(&app_dir, url);
1976 assert!(
1977 result.is_err(),
1978 "corrupt JSON within TTL must return Err, got {result:?}"
1979 );
1980 }
1981
1982 #[test]
1983 fn load_cached_valid_json_within_ttl_returns_index() {
1984 let tmp = tempfile::tempdir().unwrap();
1985 let app_dir = AppDir::new(tmp.path().to_path_buf());
1986 let url = "https://example.com/index.json";
1987 let dir = cache_dir(&app_dir);
1988 std::fs::create_dir_all(&dir).unwrap();
1989 let path = dir.join(format!("{}.json", cache_key(url)));
1990 let index_json = r#"{"schema_version":"hub_index/v0","updated_at":"2026-01-01T00:00:00Z","packages":[]}"#;
1991 std::fs::write(&path, index_json).unwrap();
1992 let result = load_cached(&app_dir, url);
1993 assert!(
1994 matches!(result, Ok(Some(_))),
1995 "valid JSON within TTL must return Ok(Some(_)), got {result:?}"
1996 );
1997 }
1998
1999 #[test]
2002 fn count_evals_for_pkg_absent_dir_returns_zero_no_warnings() {
2003 let tmp = tempfile::tempdir().unwrap();
2004 let app_dir = AppDir::new(tmp.path().to_path_buf());
2005 let mut warnings: Vec<String> = Vec::new();
2006 let count = count_evals_for_pkg(&app_dir, "cot", &mut warnings);
2007 assert_eq!(count, 0, "absent evals dir must return 0");
2008 assert!(
2009 warnings.is_empty(),
2010 "absent evals dir must produce no warnings, got {warnings:?}"
2011 );
2012 }
2013
2014 #[test]
2015 fn count_evals_for_pkg_corrupt_meta_surfaces_warning() {
2016 let tmp = tempfile::tempdir().unwrap();
2017 let app_dir = AppDir::new(tmp.path().to_path_buf());
2018 let evals_dir = app_dir.evals_dir();
2019 std::fs::create_dir_all(&evals_dir).unwrap();
2020
2021 std::fs::write(evals_dir.join("cot_9999.json"), b"{}").unwrap();
2023 std::fs::write(evals_dir.join("cot_9999.meta.json"), b"not json {{{{").unwrap();
2025
2026 let mut warnings: Vec<String> = Vec::new();
2027 let _count = count_evals_for_pkg(&app_dir, "cot", &mut warnings);
2028 assert!(
2029 !warnings.is_empty(),
2030 "corrupt meta.json must produce at least one warning, got {warnings:?}"
2031 );
2032 assert!(
2033 warnings[0].contains("parse"),
2034 "warning must mention parse: {}",
2035 warnings[0]
2036 );
2037 }
2038
2039 #[test]
2040 fn count_evals_for_pkg_valid_meta_counts_correctly() {
2041 let tmp = tempfile::tempdir().unwrap();
2042 let app_dir = AppDir::new(tmp.path().to_path_buf());
2043 let evals_dir = app_dir.evals_dir();
2044 std::fs::create_dir_all(&evals_dir).unwrap();
2045
2046 let meta = r#"{"eval_id":"cot_1","strategy":"cot","timestamp":1}"#;
2048 std::fs::write(evals_dir.join("cot_1.json"), b"{}").unwrap();
2049 std::fs::write(evals_dir.join("cot_1.meta.json"), meta).unwrap();
2050
2051 let mut warnings: Vec<String> = Vec::new();
2052 let count = count_evals_for_pkg(&app_dir, "cot", &mut warnings);
2053 assert_eq!(count, 1, "should count 1 valid eval");
2054 assert!(warnings.is_empty(), "no warnings expected: {warnings:?}");
2055 }
2056}