1use std::collections::{HashMap, HashSet};
77use std::path::PathBuf;
78
79use serde::{Deserialize, Serialize};
80
81use algocline_core::{AppDir, PkgEntity, PkgType};
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, LUA_TYPE_AUTODETECT};
89use super::source::PackageSource;
90use super::AppService;
91use super::HubRegistriesError;
92
93const CACHE_TTL_SECS: u64 = 3600;
97
98fn is_safe_pkg_name(name: &str) -> bool {
102 !name.is_empty()
103 && name
104 .bytes()
105 .all(|b| b.is_ascii_alphanumeric() || b == b'_' || b == b'-')
106}
107
108const HTTP_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(30);
110
111#[derive(Debug, Clone, Serialize, Deserialize)]
115pub(crate) struct HubIndex {
116 pub schema_version: String,
117 #[serde(default)]
118 pub updated_at: String,
119 #[serde(default)]
120 pub packages: Vec<IndexEntry>,
121}
122
123#[derive(Debug, Clone, Serialize, Deserialize)]
131pub(crate) struct IndexEntry {
132 #[serde(flatten)]
133 pub entity: PkgEntity,
134 #[serde(default)]
138 pub source: PackageSource,
139 #[serde(default)]
140 pub card_count: usize,
141 #[serde(default)]
142 pub best_card: Option<BestCard>,
143}
144
145#[derive(Debug, Clone, Serialize, Deserialize)]
147pub(crate) struct BestCard {
148 pub card_id: String,
149 #[serde(default)]
150 pub model: String,
151 #[serde(default)]
152 pub pass_rate: f64,
153 #[serde(default)]
154 pub scenario: String,
155}
156
157#[derive(Debug, Clone, Serialize)]
178struct SearchResult {
179 #[serde(flatten, serialize_with = "serialize_entity_without_docstring")]
180 entity: PkgEntity,
181 source: PackageSource,
183 installed: bool,
184 card_count: usize,
185 best_card: Option<BestCard>,
186 #[serde(skip_serializing_if = "Option::is_none")]
187 docstring_matched: Option<bool>,
188}
189
190fn serialize_entity_without_docstring<S>(entity: &PkgEntity, ser: S) -> Result<S::Ok, S::Error>
195where
196 S: serde::Serializer,
197{
198 use serde::ser::SerializeMap;
199 let mut map = ser.serialize_map(Some(6))?;
200 map.serialize_entry("name", &entity.name)?;
201 map.serialize_entry("version", &entity.version)?;
202 map.serialize_entry("description", &entity.description)?;
203 map.serialize_entry("category", &entity.category)?;
204 map.serialize_entry("tags", &entity.tags)?;
205 map.serialize_entry("type", &entity.pkg_type)?;
206 map.end()
207}
208
209impl SearchResult {
210 fn to_value_with_optional_docstring(&self, include_docstring: bool) -> serde_json::Value {
223 let mut v = serde_json::to_value(self).unwrap_or(serde_json::Value::Null);
224 if include_docstring {
225 if let serde_json::Value::Object(ref mut map) = v {
226 let doc = self.entity.docstring.clone().unwrap_or_default();
227 map.insert("docstring".to_string(), serde_json::Value::String(doc));
228 }
229 }
230 v
231 }
232}
233
234#[derive(Debug, Clone, Serialize, Deserialize)]
243pub(crate) struct RegistryEntry {
244 pub source: String,
246 pub origin: String,
248 pub added_at: String,
250}
251
252#[derive(Debug, Clone, Serialize, Deserialize, Default)]
254pub(crate) struct HubRegistries {
255 pub registries: Vec<RegistryEntry>,
256}
257
258fn registries_path(app_dir: &AppDir) -> PathBuf {
259 app_dir.hub_registries_json()
260}
261
262fn load_registries(app_dir: &AppDir) -> Result<HubRegistries, HubRegistriesError> {
270 let path = registries_path(app_dir);
271 if !path.exists() {
272 return Ok(HubRegistries::default());
273 }
274 let content = std::fs::read_to_string(&path).map_err(|e| {
275 HubRegistriesError::Parse(format!(
276 "failed to read hub_registries.json at {}: {e}",
277 path.display()
278 ))
279 })?;
280 serde_json::from_str::<HubRegistries>(&content).map_err(|e| {
281 HubRegistriesError::Parse(format!(
282 "failed to parse hub_registries.json at {}: {e}",
283 path.display()
284 ))
285 })
286}
287
288pub(crate) fn register_source(app_dir: &AppDir, source: &str, origin: &str) -> Result<(), String> {
302 let normalized = source.trim_end_matches('/').to_string();
303 if normalized.is_empty() {
304 return Ok(());
305 }
306 if normalized.starts_with('/') || normalized.starts_with('.') {
308 return Ok(());
309 }
310
311 let path = registries_path(app_dir);
312 if let Some(parent) = path.parent() {
313 std::fs::create_dir_all(parent).map_err(|e| {
314 format!(
315 "failed to create hub registries dir {}: {e}",
316 parent.display()
317 )
318 })?;
319 }
320
321 let mut reg = load_registries(app_dir).map_err(|e| format!("cannot register source: {e}"))?;
325
326 if reg
328 .registries
329 .iter()
330 .any(|e| e.source.trim_end_matches('/') == normalized)
331 {
332 return Ok(());
333 }
334
335 reg.registries.push(RegistryEntry {
336 source: normalized,
337 origin: origin.to_string(),
338 added_at: manifest::now_iso8601(),
339 });
340
341 let json = serde_json::to_string_pretty(®)
343 .map_err(|e| format!("failed to serialize hub registries: {e}"))?;
344 let tmp_path = path.with_extension("json.tmp");
345 std::fs::write(&tmp_path, &json).map_err(|e| {
346 format!(
347 "failed to write hub registries tmp {}: {e}",
348 tmp_path.display()
349 )
350 })?;
351 std::fs::rename(&tmp_path, &path).map_err(|e| {
352 let _ = std::fs::remove_file(&tmp_path);
354 format!(
355 "failed to atomically rename hub registries onto {}: {e}",
356 path.display()
357 )
358 })
359}
360
361fn collection_url_from_config(app_dir: &AppDir) -> Result<Option<String>, String> {
378 let path = app_dir.config_toml();
379 let content = match std::fs::read_to_string(&path) {
380 Ok(c) => c,
381 Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
382 Err(_) => return Ok(None), };
384 let doc: toml_edit::DocumentMut = content
385 .parse()
386 .map_err(|e| format!("config.toml parse: {e}"))?;
387 let url = match doc
388 .get("hub")
389 .and_then(|h| h.get("collection_url"))
390 .and_then(|v| v.as_str())
391 {
392 Some(s) => s.trim().to_string(),
393 None => return Ok(None),
394 };
395 if url.is_empty() {
396 Ok(None)
397 } else {
398 Ok(Some(url))
399 }
400}
401
402fn repo_to_index_url(repo_url: &str) -> Option<String> {
417 let trimmed = repo_url.trim_end_matches('/').trim_end_matches(".git");
418 if let Some(path) = trimmed.strip_prefix("https://github.com/") {
419 let parts: Vec<&str> = path.splitn(3, '/').collect();
421 if parts.len() >= 2 {
422 return Some(format!(
423 "https://raw.githubusercontent.com/{}/{}/main/hub_index.json",
424 parts[0], parts[1]
425 ));
426 }
427 }
428 if trimmed.ends_with(".json") {
430 Some(trimmed.to_string())
431 } else {
432 None
433 }
434}
435
436fn discover_index_urls(
447 app_dir: &AppDir,
448 warnings: &mut Vec<String>,
449) -> Result<Vec<String>, String> {
450 let mut index_urls: Vec<String> = Vec::new();
451
452 match collection_url_from_config(app_dir) {
457 Ok(Some(url)) => index_urls.push(url),
458 Ok(None) => {}
459 Err(e) => warnings.push(format!("config.toml hub.collection_url: {e}")),
460 }
461
462 let mut repo_urls: HashSet<String> = HashSet::new();
463
464 let reg = load_registries(app_dir).map_err(|e| e.to_string())?;
470 for entry in ®.registries {
471 let normalized = entry.source.trim_end_matches('/').to_string();
472 if !normalized.is_empty() {
473 repo_urls.insert(normalized);
474 }
475 }
476
477 let m = manifest::load_manifest(app_dir)?;
481 for entry in m.packages.values() {
482 if let Some(url) = entry.source.git_url() {
483 let normalized = url.trim_end_matches('/').to_string();
484 if !normalized.is_empty() {
485 repo_urls.insert(normalized);
486 }
487 }
488 }
489
490 for url in AUTO_INSTALL_SOURCES {
492 repo_urls.insert(url.to_string());
493 }
494
495 let existing: HashSet<String> = index_urls.iter().cloned().collect();
497 let mut derived: Vec<String> = repo_urls
498 .iter()
499 .filter_map(|url| repo_to_index_url(url))
500 .filter(|url| !existing.contains(url))
501 .collect();
502 derived.sort();
503 derived.dedup();
504 index_urls.extend(derived);
505
506 Ok(index_urls)
507}
508
509fn cache_dir(app_dir: &AppDir) -> PathBuf {
517 app_dir.hub_cache_dir()
518}
519
520fn cache_key(url: &str) -> String {
521 let mut h: u64 = 0xcbf2_9ce4_8422_2325; for b in url.as_bytes() {
525 h ^= *b as u64;
526 h = h.wrapping_mul(0x0100_0000_01b3); }
528 format!("{h:016x}")
529}
530
531enum CacheLookup {
538 NotPresent,
540 Stale(HubIndex),
542 Fresh(HubIndex),
544 Corrupt(String),
546}
547
548fn load_cached_full(app_dir: &AppDir, url: &str) -> CacheLookup {
553 let dir = cache_dir(app_dir);
554 let path = dir.join(format!("{}.json", cache_key(url)));
555 if !path.exists() {
556 return CacheLookup::NotPresent;
557 }
558 let metadata = match std::fs::metadata(&path) {
559 Ok(m) => m,
560 Err(_) => return CacheLookup::NotPresent,
561 };
562 let age = match metadata.modified().ok().and_then(|t| t.elapsed().ok()) {
563 Some(a) => a,
564 None => return CacheLookup::NotPresent,
565 };
566 let content = match std::fs::read_to_string(&path) {
567 Ok(c) => c,
568 Err(e) => return CacheLookup::Corrupt(format!("hub cache read {}: {e}", path.display())),
569 };
570 match serde_json::from_str::<HubIndex>(&content) {
571 Ok(index) => {
572 if age.as_secs() > CACHE_TTL_SECS {
573 CacheLookup::Stale(index)
574 } else {
575 CacheLookup::Fresh(index)
576 }
577 }
578 Err(e) => CacheLookup::Corrupt(format!("hub cache parse {}: {e}", path.display())),
579 }
580}
581
582fn load_cached(app_dir: &AppDir, url: &str) -> Result<Option<HubIndex>, String> {
590 match load_cached_full(app_dir, url) {
591 CacheLookup::Fresh(index) => Ok(Some(index)),
592 CacheLookup::NotPresent | CacheLookup::Stale(_) => Ok(None),
593 CacheLookup::Corrupt(msg) => Err(msg),
594 }
595}
596
597fn save_cached(app_dir: &AppDir, url: &str, index: &HubIndex) -> Result<(), String> {
604 let dir = cache_dir(app_dir);
605 std::fs::create_dir_all(&dir)
606 .map_err(|e| format!("failed to create hub cache dir {}: {e}", dir.display()))?;
607 let path = dir.join(format!("{}.json", cache_key(url)));
608 let json = serde_json::to_string_pretty(index)
609 .map_err(|e| format!("failed to serialize hub cache: {e}"))?;
610 std::fs::write(&path, json)
611 .map_err(|e| format!("failed to write hub cache {}: {e}", path.display()))
612}
613
614fn fetch_one(app_dir: &AppDir, url: &str) -> Result<(HubIndex, Option<String>), String> {
626 match load_cached(app_dir, url) {
628 Ok(Some(cached)) => return Ok((cached, None)),
629 Ok(None) => {} Err(e) => {
631 let warn = format!("hub cache corrupted for {url}: {e}; falling back to network");
635 return fetch_one_from_network(app_dir, url)
637 .map(|(idx, save_warn)| {
638 let combined = Some(match save_warn {
640 Some(sw) => format!("{warn}; {sw}"),
641 None => warn.clone(),
642 });
643 (idx, combined)
644 })
645 .map_err(|fetch_err| format!("{warn}; network fetch also failed: {fetch_err}"));
646 }
647 }
648
649 fetch_one_from_network(app_dir, url)
650}
651
652fn fetch_one_from_network(
656 app_dir: &AppDir,
657 url: &str,
658) -> Result<(HubIndex, Option<String>), String> {
659 let agent = ureq::Agent::new_with_config(
660 ureq::config::Config::builder()
661 .timeout_global(Some(HTTP_TIMEOUT))
662 .build(),
663 );
664 let body: String = agent
665 .get(url)
666 .call()
667 .map_err(|e| format!("Failed to fetch {url}: {e}"))?
668 .body_mut()
669 .read_to_string()
670 .map_err(|e| format!("Failed to read response from {url}: {e}"))?;
671
672 let index: HubIndex = serde_json::from_str(&body)
673 .map_err(|e| format!("Failed to parse index from {url}: {e}"))?;
674
675 let cache_warning = save_cached(app_dir, url, &index)
676 .err()
677 .map(|e| format!("hub cache write for {url}: {e}"));
678 Ok((index, cache_warning))
679}
680
681fn fetch_remote_indices(app_dir: &AppDir) -> Result<(HubIndex, Vec<String>), String> {
684 let mut warnings: Vec<String> = Vec::new();
685 let urls = discover_index_urls(app_dir, &mut warnings)?;
686 let mut all_packages: Vec<IndexEntry> = Vec::new();
687 let mut seen_names: HashSet<String> = HashSet::new();
688
689 for url in &urls {
690 match fetch_one(app_dir, url) {
691 Ok((index, cache_warning)) => {
692 for entry in index.packages {
693 if seen_names.insert(entry.entity.name.clone()) {
694 all_packages.push(entry);
695 }
696 }
698 if let Some(w) = cache_warning {
699 warnings.push(w);
700 }
701 }
702 Err(e) => {
703 warnings.push(e);
704 }
705 }
706 }
707
708 if all_packages.is_empty() && !warnings.is_empty() {
709 warnings.insert(
710 0,
711 "all remote indices unavailable, showing local packages only".to_string(),
712 );
713 }
714
715 let merged = HubIndex {
716 schema_version: "hub_index/v0".into(),
717 updated_at: String::new(),
718 packages: all_packages,
719 };
720 Ok((merged, warnings))
721}
722
723fn installed_packages(app_dir: &AppDir) -> Result<HashMap<String, Option<String>>, String> {
728 let mut map = HashMap::new();
729
730 let m = manifest::load_manifest(app_dir)?;
732 for (name, entry) in &m.packages {
733 map.insert(name.clone(), entry.version.clone());
734 }
735
736 let pkg_dir = app_dir.packages_dir();
738 if let Ok(entries) = std::fs::read_dir(&pkg_dir) {
739 for entry in entries.flatten() {
740 if entry.path().is_dir() {
741 if let Some(name) = entry.file_name().to_str() {
742 map.entry(name.to_string()).or_insert(None);
743 }
744 }
745 }
746 }
747
748 Ok(map)
749}
750
751fn local_card_counts(app_dir: &AppDir) -> HashMap<String, usize> {
753 let mut map = HashMap::new();
754 let cards_dir = app_dir.cards_dir();
755 let entries = match std::fs::read_dir(&cards_dir) {
756 Ok(e) => e,
757 Err(_) => return map,
758 };
759 for entry in entries.flatten() {
760 if !entry.path().is_dir() {
761 continue;
762 }
763 let pkg = match entry.file_name().to_str() {
764 Some(n) => n.to_string(),
765 None => continue,
766 };
767 let count = std::fs::read_dir(entry.path())
768 .map(|es| {
769 es.flatten()
770 .filter(|e| e.path().extension().is_some_and(|ext| ext == "toml"))
771 .count()
772 })
773 .unwrap_or(0);
774 if count > 0 {
775 map.insert(pkg, count);
776 }
777 }
778 map
779}
780
781fn count_evals_for_pkg(app_dir: &AppDir, pkg: &str, warnings: &mut Vec<String>) -> usize {
792 let evals_dir = app_dir.evals_dir();
793 let entries = match std::fs::read_dir(&evals_dir) {
794 Ok(e) => e,
795 Err(_) => return 0,
796 };
797
798 let mut meta_stems: HashSet<String> = HashSet::new();
801 let mut meta_matches: usize = 0;
802 let mut non_meta_paths: Vec<(PathBuf, String)> = Vec::new(); for entry in entries.flatten() {
805 let path = entry.path();
806 let name = match path.file_name().and_then(|n| n.to_str()) {
807 Some(n) => n.to_string(),
808 None => continue,
809 };
810
811 if name.ends_with(".meta.json") {
812 let stem = name.trim_end_matches(".meta.json").to_string();
813 meta_stems.insert(stem.clone());
814 match std::fs::read_to_string(&path) {
816 Ok(content) => match serde_json::from_str::<serde_json::Value>(&content) {
817 Ok(val) => {
818 if val.get("strategy").and_then(|s| s.as_str()) == Some(pkg) {
819 meta_matches += 1;
820 }
821 }
822 Err(e) => warnings.push(format!("eval meta parse {}: {e}", path.display())),
823 },
824 Err(e) => warnings.push(format!("eval meta read {}: {e}", path.display())),
825 }
826 continue;
827 }
828
829 if !name.ends_with(".json") || name.starts_with("compare_") {
831 continue;
832 }
833
834 let stem = path
835 .file_stem()
836 .and_then(|s| s.to_str())
837 .unwrap_or("")
838 .to_string();
839 non_meta_paths.push((path, stem));
840 }
841
842 let mut fallback_matches: usize = 0;
845 for (path, stem) in &non_meta_paths {
846 if meta_stems.contains(stem) {
847 continue;
848 }
849 match std::fs::read_to_string(path) {
850 Ok(c) => match serde_json::from_str::<serde_json::Value>(&c) {
851 Ok(v) => {
852 if v.get("strategy").and_then(|s| s.as_str()) == Some(pkg) {
853 fallback_matches += 1;
854 }
855 }
856 Err(e) => warnings.push(format!("eval result parse {}: {e}", path.display())),
857 },
858 Err(e) => warnings.push(format!("eval result read {}: {e}", path.display())),
859 }
860 }
861
862 meta_matches + fallback_matches
863}
864
865fn merge(app_dir: &AppDir, remote: &HubIndex) -> Result<Vec<SearchResult>, String> {
873 let installed = installed_packages(app_dir)?;
874 let card_counts = local_card_counts(app_dir);
875 let pkg_dir: Option<PathBuf> = Some(app_dir.packages_dir());
876
877 let mut seen: HashSet<String> = HashSet::new();
878 let mut results: Vec<SearchResult> = Vec::new();
879
880 for entry in &remote.packages {
881 let pkg_name = &entry.entity.name;
882 let is_installed = installed.contains_key(pkg_name);
883 let local_cards = card_counts.get(pkg_name).copied().unwrap_or(0);
884
885 let docstring = if entry.entity.docstring.as_deref().unwrap_or("").is_empty()
889 && is_installed
890 {
891 pkg_dir
892 .as_ref()
893 .and_then(|d| PkgEntity::parse_from_init_lua(&d.join(pkg_name).join("init.lua")))
894 .and_then(|e| e.docstring)
895 } else {
896 entry.entity.docstring.clone()
897 };
898
899 seen.insert(pkg_name.clone());
900 let mut merged_entity = entry.entity.clone();
901 merged_entity.docstring = docstring;
902 merged_entity.pkg_type = merged_entity.pkg_type.or(Some(PkgType::Runnable));
903 results.push(SearchResult {
904 entity: merged_entity,
905 source: entry.source.clone(),
906 installed: is_installed,
907 card_count: if is_installed && local_cards > entry.card_count {
908 local_cards
909 } else {
910 entry.card_count
911 },
912 best_card: entry.best_card.clone(),
913 docstring_matched: None,
914 });
915 }
916
917 for (name, version) in &installed {
919 if seen.contains(name) {
920 continue;
921 }
922 let parsed_entity = pkg_dir
929 .as_ref()
930 .and_then(|d| PkgEntity::parse_from_init_lua(&d.join(name).join("init.lua")));
931 let entity = parsed_entity.unwrap_or(PkgEntity {
932 name: name.clone(),
933 version: version.clone(),
934 description: None,
935 category: None,
936 docstring: None,
937 tags: None,
938 pkg_type: Some(PkgType::Runnable),
939 type_source: None,
940 });
941 results.push(SearchResult {
942 entity,
943 source: PackageSource::Unknown,
944 installed: true,
945 card_count: card_counts.get(name).copied().unwrap_or(0),
946 best_card: None,
947 docstring_matched: None,
948 });
949 }
950
951 Ok(results)
952}
953
954fn matches_query(result: &SearchResult, query: &str) -> bool {
957 let q = query.to_lowercase();
958 let pkg = &result.entity;
959 let empty = String::new();
960 pkg.name.to_lowercase().contains(&q)
961 || pkg
962 .description
963 .as_ref()
964 .unwrap_or(&empty)
965 .to_lowercase()
966 .contains(&q)
967 || pkg
968 .category
969 .as_ref()
970 .unwrap_or(&empty)
971 .to_lowercase()
972 .contains(&q)
973 || pkg
974 .docstring
975 .as_ref()
976 .unwrap_or(&empty)
977 .to_lowercase()
978 .contains(&q)
979 || pkg
980 .tags
981 .as_ref()
982 .is_some_and(|tags| tags.iter().any(|tag| tag.to_lowercase().contains(&q)))
983}
984
985async fn build_index(
1002 app_dir: &AppDir,
1003 source_dir: Option<&std::path::Path>,
1004 executor: &std::sync::Arc<algocline_engine::Executor>,
1005) -> Result<HubIndex, String> {
1006 let empty = || HubIndex {
1007 schema_version: "hub_index/v0".into(),
1008 updated_at: super::manifest::now_iso8601(),
1009 packages: Vec::new(),
1010 };
1011
1012 let pkg_dir = match source_dir {
1013 Some(d) => d.to_path_buf(),
1014 None => app_dir.packages_dir(),
1015 };
1016
1017 let use_local_state = source_dir.is_none();
1018 let card_counts = if use_local_state {
1019 local_card_counts(app_dir)
1020 } else {
1021 HashMap::new()
1022 };
1023 let manifest = if use_local_state {
1030 manifest::load_manifest(app_dir)?
1031 } else {
1032 manifest::Manifest::default()
1033 };
1034
1035 let mut entries = Vec::new();
1036
1037 let dir_entries = match std::fs::read_dir(&pkg_dir) {
1041 Ok(e) => e,
1042 Err(_) => return Ok(empty()),
1043 };
1044
1045 for entry in dir_entries.flatten() {
1046 if !entry.path().is_dir() {
1047 continue;
1048 }
1049 let dir_name = match entry.file_name().to_str() {
1050 Some(n) if !n.starts_with('.') && !n.starts_with('_') => n.to_string(),
1051 _ => continue,
1052 };
1053
1054 let init_lua = entry.path().join("init.lua");
1055 if !init_lua.exists() {
1056 continue;
1057 }
1058
1059 let Some(mut entity) = PkgEntity::parse_from_init_lua(&init_lua) else {
1066 continue;
1067 };
1068
1069 entity.pkg_type = if is_safe_pkg_name(&dir_name) {
1075 let code = format!(
1076 r#"package.loaded["{name}"] = nil
1077local pkg = require("{name}")
1078local meta = pkg.meta or {{ name = "{name}" }}
1079{LUA_TYPE_AUTODETECT}
1080return meta"#,
1081 name = dir_name,
1082 LUA_TYPE_AUTODETECT = LUA_TYPE_AUTODETECT,
1083 );
1084 let eval_result = if source_dir.is_some() {
1085 executor
1088 .eval_simple_with_paths(code, vec![pkg_dir.clone()], vec![])
1089 .await
1090 } else {
1091 executor.eval_simple(code).await
1092 };
1093 match eval_result {
1094 Ok(meta) => meta
1095 .get("type")
1096 .and_then(|v| v.as_str())
1097 .and_then(|s| s.parse::<algocline_core::PkgType>().ok()),
1098 Err(e) => {
1099 tracing::warn!("hub: build_index VM eval failed for {dir_name}: {e}");
1100 None
1101 }
1102 }
1103 } else {
1104 None
1105 };
1106
1107 let source = manifest
1111 .packages
1112 .get(&dir_name)
1113 .map(|e| e.source.clone())
1114 .unwrap_or_default();
1115
1116 entries.push(IndexEntry {
1117 entity,
1118 source,
1119 card_count: card_counts.get(&dir_name).copied().unwrap_or(0),
1120 best_card: None,
1121 });
1122 }
1123
1124 entries.sort_by(|a, b| a.entity.name.cmp(&b.entity.name));
1125
1126 Ok(HubIndex {
1127 schema_version: "hub_index/v0".into(),
1128 updated_at: super::manifest::now_iso8601(),
1129 packages: entries,
1130 })
1131}
1132
1133impl AppService {
1136 pub async fn hub_reindex(
1145 &self,
1146 output_path: Option<&str>,
1147 source_dir: Option<&str>,
1148 ) -> Result<String, String> {
1149 let src = source_dir.map(std::path::Path::new);
1150 if let Some(d) = src {
1151 if !d.is_dir() {
1152 return Err(format!("source_dir '{}' is not a directory", d.display()));
1153 }
1154 }
1155 let app_dir = self.log_config.app_dir();
1156 let index = build_index(&app_dir, src, &self.executor).await?;
1157
1158 let written_path = if let Some(path) = output_path {
1159 let json = serde_json::to_string_pretty(&index)
1160 .map_err(|e| format!("Failed to serialize index: {e}"))?;
1161 std::fs::write(path, &json)
1162 .map_err(|e| format!("Failed to write index to {path}: {e}"))?;
1163 Some(path.to_string())
1164 } else {
1165 None
1166 };
1167
1168 let response = serde_json::json!({
1169 "package_count": index.packages.len(),
1170 "updated_at": index.updated_at,
1171 "output_path": written_path,
1172 "source_dir": source_dir,
1173 });
1174 Ok(response.to_string())
1175 }
1176
1177 pub fn hub_info(&self, pkg: &str) -> Result<String, String> {
1182 use algocline_engine::card;
1183
1184 if pkg.contains("..") || pkg.contains('/') || pkg.contains('\\') {
1186 return Err(format!("Invalid package name: '{pkg}'"));
1187 }
1188
1189 let app_dir = self.log_config.app_dir();
1191 let installed = installed_packages(&app_dir)?;
1192 let is_installed = installed.contains_key(pkg);
1193
1194 let (version, description, category, source) = {
1200 let (remote, _) = fetch_remote_indices(&app_dir)?;
1201 if let Some(entry) = remote.packages.iter().find(|e| e.entity.name == pkg) {
1202 (
1203 entry.entity.version.clone().unwrap_or_default(),
1204 entry.entity.description.clone().unwrap_or_default(),
1205 entry.entity.category.clone().unwrap_or_default(),
1206 entry.source.clone(),
1207 )
1208 } else if is_installed {
1209 let init_lua = app_dir.packages_dir().join(pkg).join("init.lua");
1215 let entity = PkgEntity::parse_from_init_lua(&init_lua);
1216 let manifest_source = manifest::load_manifest(&app_dir)?
1217 .packages
1218 .get(pkg)
1219 .map(|e| e.source.clone())
1220 .unwrap_or_default();
1221 match entity {
1222 Some(e) => (
1223 e.version.unwrap_or_default(),
1224 e.description.unwrap_or_default(),
1225 e.category.unwrap_or_default(),
1226 manifest_source,
1227 ),
1228 None => (
1229 installed.get(pkg).cloned().flatten().unwrap_or_default(),
1230 String::new(),
1231 String::new(),
1232 manifest_source,
1233 ),
1234 }
1235 } else {
1236 return Err(format!(
1237 "Package '{pkg}' not found in remote indices or locally installed packages"
1238 ));
1239 }
1240 };
1241
1242 let mut warnings: Vec<String> = Vec::new();
1246
1247 let card_rows = match self.card_store.list(Some(pkg)) {
1249 Ok(rows) => rows,
1250 Err(e) => {
1251 let msg = format!("card store list for '{pkg}': {e}");
1252 tracing::warn!("{}", msg);
1253 warnings.push(msg);
1254 vec![]
1255 }
1256 };
1257 let cards_json = card::summaries_to_json(&card_rows);
1258
1259 let aliases_json = match self.card_store.alias_list(Some(pkg)) {
1261 Ok(rows) => card::aliases_to_json(&rows),
1262 Err(e) => {
1263 let msg = format!("card store alias_list for '{pkg}': {e}");
1264 tracing::warn!("{}", msg);
1265 warnings.push(msg);
1266 serde_json::json!([])
1267 }
1268 };
1269
1270 let card_count = card_rows.len();
1272 let best_pass_rate = card_rows
1273 .iter()
1274 .filter_map(|c| c.pass_rate)
1275 .fold(f64::NEG_INFINITY, f64::max);
1276 let best_pass_rate = if best_pass_rate.is_finite() {
1277 Some(best_pass_rate)
1278 } else {
1279 None
1280 };
1281
1282 let eval_count = count_evals_for_pkg(&app_dir, pkg, &mut warnings);
1284
1285 let mut response = serde_json::json!({
1286 "pkg": {
1287 "name": pkg,
1288 "version": version,
1289 "description": description,
1290 "category": category,
1291 "source": source,
1292 "installed": is_installed,
1293 },
1294 "cards": cards_json,
1295 "aliases": aliases_json,
1296 "stats": {
1297 "card_count": card_count,
1298 "eval_count": eval_count,
1299 "best_pass_rate": best_pass_rate,
1300 },
1301 });
1302 if !warnings.is_empty() {
1303 response["warnings"] = serde_json::json!(warnings);
1304 }
1305 Ok(response.to_string())
1306 }
1307
1308 pub(crate) fn hub_search(
1340 &self,
1341 query: Option<&str>,
1342 category: Option<&str>,
1343 installed_only: Option<bool>,
1344 opts: ListOpts,
1345 local_indices: Option<Vec<String>>,
1346 ) -> Result<String, String> {
1347 let app_dir = self.log_config.app_dir();
1348 let (mut remote, mut warnings) = fetch_remote_indices(&app_dir)?;
1349
1350 let local_index_paths: Vec<String> = local_indices.clone().unwrap_or_default();
1360 if let Some(paths) = local_indices {
1361 let mut existing: HashSet<String> = remote
1362 .packages
1363 .iter()
1364 .map(|p| p.entity.name.clone())
1365 .collect();
1366 for path in &paths {
1367 match std::fs::read_to_string(path) {
1368 Err(e) => {
1369 warnings.push(format!("Failed to read local index {path}: {e}"));
1370 }
1371 Ok(raw) => match serde_json::from_str::<HubIndex>(&raw) {
1372 Err(e) => {
1373 warnings.push(format!("Failed to parse local index {path}: {e}"));
1374 }
1375 Ok(idx) => {
1376 for entry in idx.packages {
1377 if existing.insert(entry.entity.name.clone()) {
1378 remote.packages.push(entry);
1379 }
1380 }
1381 }
1382 },
1383 }
1384 }
1385 }
1386
1387 let mut results = merge(&app_dir, &remote)?;
1388
1389 let query_lower = query.filter(|q| !q.is_empty()).map(|q| q.to_lowercase());
1392 if let Some(ref ql) = query_lower {
1393 results.retain(|r| matches_query(r, ql));
1394 }
1395
1396 if let Some(ref ql) = query_lower {
1400 for r in &mut results {
1401 let empty = String::new();
1402 let pkg = &r.entity;
1403 let other_hit = pkg.name.to_lowercase().contains(ql)
1404 || pkg
1405 .description
1406 .as_ref()
1407 .unwrap_or(&empty)
1408 .to_lowercase()
1409 .contains(ql)
1410 || pkg
1411 .category
1412 .as_ref()
1413 .unwrap_or(&empty)
1414 .to_lowercase()
1415 .contains(ql);
1416 let doc_hit = pkg
1417 .docstring
1418 .as_ref()
1419 .unwrap_or(&empty)
1420 .to_lowercase()
1421 .contains(ql);
1422 r.docstring_matched = if !other_hit && doc_hit {
1423 Some(true)
1424 } else {
1425 None
1426 };
1427 }
1428 }
1429
1430 let mut filter_map: std::collections::HashMap<String, serde_json::Value> =
1434 opts.filter.unwrap_or_default();
1435 if let Some(cat) = category {
1436 filter_map
1437 .entry("category".to_string())
1438 .or_insert_with(|| serde_json::Value::String(cat.to_string()));
1439 }
1440 if let Some(only) = installed_only {
1441 if only {
1445 filter_map
1446 .entry("installed".to_string())
1447 .or_insert(serde_json::Value::Bool(true));
1448 }
1449 }
1450
1451 let sort_str = opts.sort.as_deref().unwrap_or("-installed,name");
1454 let sort_keys = parse_sort(sort_str)?;
1455
1456 let fields = resolve_fields(
1459 opts.verbose.as_deref(),
1460 opts.fields.as_deref(),
1461 HUB_SEARCH_SUMMARY,
1462 HUB_SEARCH_FULL,
1463 )?;
1464 let include_docstring = fields.iter().any(|f| f == "docstring");
1465
1466 let mut items: Vec<serde_json::Value> = results
1469 .iter()
1470 .map(|r| r.to_value_with_optional_docstring(include_docstring))
1471 .collect();
1472
1473 if !filter_map.is_empty() {
1476 items.retain(|v| matches_filter(v, &filter_map));
1477 }
1478
1479 apply_sort_by_value(&mut items, &sort_keys);
1481
1482 let total = items.len();
1486 let limit = opts.limit.unwrap_or(50);
1487 if limit > 0 {
1488 items.truncate(limit);
1489 }
1490
1491 let projected: Vec<serde_json::Value> = items
1494 .into_iter()
1495 .map(|v| project_fields(v, &fields))
1496 .collect();
1497
1498 let mut _src_warnings: Vec<String> = Vec::new();
1503 let mut sources = discover_index_urls(&app_dir, &mut _src_warnings)?;
1504 sources.extend(local_index_paths);
1507
1508 let mut json = serde_json::json!({
1509 "results": projected,
1510 "total": total,
1511 "sources": sources,
1512 });
1513 if !warnings.is_empty() {
1514 json["warnings"] = serde_json::json!(warnings);
1515 }
1516 Ok(json.to_string())
1517 }
1518
1519 pub(crate) fn aggregate_index(
1535 &self,
1536 ) -> Result<(HubIndex, Vec<String>), super::error::ServiceError> {
1537 let app_dir = self.log_config.app_dir();
1538 let mut warnings: Vec<String> = Vec::new();
1539
1540 let urls = match discover_index_urls(&app_dir, &mut warnings) {
1545 Ok(u) => u,
1546 Err(e) => {
1547 warnings.push(format!("hub registry discovery failed: {e}"));
1548 return Ok((
1549 HubIndex {
1550 schema_version: "hub_index/v0".into(),
1551 updated_at: String::new(),
1552 packages: Vec::new(),
1553 },
1554 warnings,
1555 ));
1556 }
1557 };
1558
1559 if urls.is_empty() {
1561 return Ok((
1562 HubIndex {
1563 schema_version: "hub_index/v0".into(),
1564 updated_at: String::new(),
1565 packages: Vec::new(),
1566 },
1567 warnings,
1568 ));
1569 }
1570
1571 let mut all_packages: Vec<IndexEntry> = Vec::new();
1578 let mut seen_names: std::collections::HashSet<String> = std::collections::HashSet::new();
1579
1580 for url in &urls {
1581 let merge_packages =
1582 |packages: Vec<IndexEntry>,
1583 all: &mut Vec<IndexEntry>,
1584 seen: &mut std::collections::HashSet<String>| {
1585 for entry in packages {
1586 if seen.insert(entry.entity.name.clone()) {
1587 all.push(entry);
1588 }
1589 }
1590 };
1591 match load_cached_full(&app_dir, url) {
1592 CacheLookup::Fresh(index) => {
1593 merge_packages(index.packages, &mut all_packages, &mut seen_names);
1594 }
1595 CacheLookup::Stale(index) => {
1596 warnings.push(format!(
1599 "hub cache stale (>{CACHE_TTL_SECS}s) for {url}; run alc_hub_search to refresh"
1600 ));
1601 merge_packages(index.packages, &mut all_packages, &mut seen_names);
1602 }
1603 CacheLookup::NotPresent => {
1604 }
1606 CacheLookup::Corrupt(e) => {
1607 warnings.push(format!("hub cache read failed for {url}: {e}"));
1609 }
1610 }
1611 }
1612
1613 Ok((
1614 HubIndex {
1615 schema_version: "hub_index/v0".into(),
1616 updated_at: String::new(),
1617 packages: all_packages,
1618 },
1619 warnings,
1620 ))
1621 }
1622}
1623
1624#[cfg(test)]
1625mod tests {
1626 use super::*;
1627
1628 #[test]
1629 fn repo_to_index_url_github() {
1630 assert_eq!(
1631 repo_to_index_url("https://github.com/ynishi/algocline-bundled-packages"),
1632 Some(
1633 "https://raw.githubusercontent.com/ynishi/algocline-bundled-packages/main/hub_index.json"
1634 .to_string()
1635 )
1636 );
1637 }
1638
1639 #[test]
1640 fn repo_to_index_url_github_trailing_slash() {
1641 assert_eq!(
1642 repo_to_index_url("https://github.com/user/repo/"),
1643 Some("https://raw.githubusercontent.com/user/repo/main/hub_index.json".to_string())
1644 );
1645 }
1646
1647 #[test]
1648 fn repo_to_index_url_github_dot_git() {
1649 assert_eq!(
1650 repo_to_index_url("https://github.com/user/repo.git"),
1651 Some("https://raw.githubusercontent.com/user/repo/main/hub_index.json".to_string())
1652 );
1653 }
1654
1655 #[test]
1656 fn repo_to_index_url_direct_json() {
1657 assert_eq!(
1658 repo_to_index_url("https://example.com/my_index.json"),
1659 Some("https://example.com/my_index.json".to_string())
1660 );
1661 }
1662
1663 #[test]
1664 fn repo_to_index_url_unknown_host_no_json() {
1665 assert_eq!(repo_to_index_url("https://example.com/some-repo"), None);
1666 }
1667
1668 #[test]
1669 fn repo_to_index_url_local_path() {
1670 assert_eq!(repo_to_index_url("/home/user/my-pkg"), None);
1671 }
1672
1673 #[test]
1674 fn cache_key_stable() {
1675 let k1 = cache_key("https://example.com/index.json");
1676 let k2 = cache_key("https://example.com/index.json");
1677 assert_eq!(k1, k2);
1678 assert_eq!(k1.len(), 16); }
1680
1681 #[test]
1682 fn cache_key_different_urls() {
1683 let k1 = cache_key("https://a.com/index.json");
1684 let k2 = cache_key("https://b.com/index.json");
1685 assert_ne!(k1, k2);
1686 }
1687
1688 #[test]
1694 fn merge_dedup_uses_hashset() {
1695 let tmp = tempfile::tempdir().unwrap();
1698 let app_dir = AppDir::new(tmp.path().to_path_buf());
1699 let remote = HubIndex {
1700 schema_version: "hub_index/v0".into(),
1701 updated_at: String::new(),
1702 packages: vec![IndexEntry {
1703 entity: PkgEntity {
1704 name: "remote_only".into(),
1705 version: Some("1.0".into()),
1706 description: Some("from remote".into()),
1707 category: Some("test".into()),
1708 docstring: None,
1709 tags: None,
1710 pkg_type: None,
1711 type_source: None,
1712 },
1713 source: PackageSource::Unknown,
1714 card_count: 0,
1715 best_card: None,
1716 }],
1717 };
1718
1719 let results = merge(&app_dir, &remote).expect("merge over empty app_dir should succeed");
1720 assert!(results.iter().any(|r| r.entity.name == "remote_only"));
1722 let remote_result = results
1723 .iter()
1724 .find(|r| r.entity.name == "remote_only")
1725 .unwrap();
1726 assert_eq!(
1727 remote_result.entity.pkg_type,
1728 Some(PkgType::Runnable),
1729 "pre-type-system index entry must default to Runnable"
1730 );
1731 }
1732
1733 #[test]
1734 fn matches_query_searches_docstring() {
1735 let result = SearchResult {
1736 entity: PkgEntity {
1737 name: "cascade".into(),
1738 version: Some("0.1.0".into()),
1739 description: Some("Multi-level routing".into()),
1740 category: Some("meta".into()),
1741 docstring: Some("Based on FrugalGPT. Uses Thompson Sampling.".into()),
1742 tags: None,
1743 pkg_type: None,
1744 type_source: None,
1745 },
1746 source: PackageSource::Unknown,
1747 installed: true,
1748 card_count: 0,
1749 best_card: None,
1750 docstring_matched: None,
1751 };
1752
1753 assert!(matches_query(&result, "thompson"), "docstring match");
1754 assert!(matches_query(&result, "FrugalGPT"), "docstring match case");
1755 assert!(matches_query(&result, "routing"), "description match");
1756 assert!(!matches_query(&result, "bayesian"), "no match");
1757 }
1758
1759 fn sample_search_result() -> SearchResult {
1768 SearchResult {
1769 entity: PkgEntity {
1770 name: "cascade".into(),
1771 version: Some("0.1.0".into()),
1772 description: Some("Multi-level routing".into()),
1773 category: Some("reasoning".into()),
1774 docstring: Some("Based on FrugalGPT. Uses Thompson Sampling.".into()),
1775 tags: None,
1776 pkg_type: None,
1777 type_source: None,
1778 },
1779 source: PackageSource::Git {
1780 url: "https://example.com/cascade".into(),
1781 rev: None,
1782 },
1783 installed: true,
1784 card_count: 3,
1785 best_card: None,
1786 docstring_matched: None,
1787 }
1788 }
1789
1790 #[test]
1791 fn to_value_default_omits_docstring() {
1792 let r = sample_search_result();
1793 let v = r.to_value_with_optional_docstring(false);
1794 let obj = v.as_object().expect("object");
1795 assert!(
1796 !obj.contains_key("docstring"),
1797 "default summary must not leak docstring"
1798 );
1799 assert_eq!(obj.get("name").and_then(|x| x.as_str()), Some("cascade"));
1800 assert!(
1803 !obj.contains_key("docstring_matched"),
1804 "docstring_matched=None must be omitted"
1805 );
1806 assert!(
1811 obj.contains_key("type"),
1812 "type key must always be present in serialized output"
1813 );
1814 assert!(
1815 obj.get("type").map(|v| v.is_null()).unwrap_or(false),
1816 "type must be null when pkg_type=None"
1817 );
1818 }
1819
1820 #[test]
1821 fn to_value_include_reattaches_docstring() {
1822 let r = sample_search_result();
1823 let v = r.to_value_with_optional_docstring(true);
1824 let obj = v.as_object().expect("object");
1825 assert_eq!(
1826 obj.get("docstring").and_then(|x| x.as_str()),
1827 Some("Based on FrugalGPT. Uses Thompson Sampling.")
1828 );
1829 }
1830
1831 #[test]
1832 fn to_value_serializes_docstring_matched_when_set() {
1833 let mut r = sample_search_result();
1834 r.docstring_matched = Some(true);
1835 let v = r.to_value_with_optional_docstring(false);
1836 let obj = v.as_object().expect("object");
1837 assert_eq!(
1838 obj.get("docstring_matched").and_then(|x| x.as_bool()),
1839 Some(true)
1840 );
1841 }
1842
1843 #[test]
1853 fn hub_search_default_summary_excludes_docstring() {
1854 let r = sample_search_result();
1855 let fields = resolve_fields(None, None, HUB_SEARCH_SUMMARY, HUB_SEARCH_FULL).unwrap();
1856 let include_docstring = fields.iter().any(|f| f == "docstring");
1857 let v = project_fields(
1858 r.to_value_with_optional_docstring(include_docstring),
1859 &fields,
1860 );
1861 let obj = v.as_object().expect("object");
1862 assert!(
1863 !obj.contains_key("docstring"),
1864 "summary preset must omit docstring"
1865 );
1866 for key in ["name", "version", "description", "category", "installed"] {
1868 assert!(obj.contains_key(key), "summary preset key {key} missing");
1869 }
1870 }
1871
1872 #[test]
1873 fn hub_search_verbose_full_includes_docstring() {
1874 let r = sample_search_result();
1875 let fields =
1876 resolve_fields(Some("full"), None, HUB_SEARCH_SUMMARY, HUB_SEARCH_FULL).unwrap();
1877 let include_docstring = fields.iter().any(|f| f == "docstring");
1878 let v = project_fields(
1879 r.to_value_with_optional_docstring(include_docstring),
1880 &fields,
1881 );
1882 let obj = v.as_object().expect("object");
1883 assert_eq!(
1884 obj.get("docstring").and_then(|x| x.as_str()),
1885 Some("Based on FrugalGPT. Uses Thompson Sampling.")
1886 );
1887 for key in ["source", "card_count"] {
1889 assert!(obj.contains_key(key), "full preset key {key} missing");
1890 }
1891 }
1892
1893 #[test]
1894 fn hub_search_fields_beats_verbose() {
1895 let r = sample_search_result();
1896 let explicit = vec!["name".to_string(), "docstring".to_string()];
1897 let fields = resolve_fields(
1900 Some("summary"),
1901 Some(&explicit),
1902 HUB_SEARCH_SUMMARY,
1903 HUB_SEARCH_FULL,
1904 )
1905 .unwrap();
1906 let include_docstring = fields.iter().any(|f| f == "docstring");
1907 let v = project_fields(
1908 r.to_value_with_optional_docstring(include_docstring),
1909 &fields,
1910 );
1911 let obj = v.as_object().expect("object");
1912 assert_eq!(obj.len(), 2, "only the two requested fields");
1913 assert!(obj.contains_key("name"));
1914 assert!(obj.contains_key("docstring"));
1915 }
1916
1917 #[test]
1918 fn hub_search_fields_unknown_key_silently_skipped() {
1919 let r = sample_search_result();
1920 let explicit = vec!["name".to_string(), "bogus".to_string()];
1921 let fields =
1922 resolve_fields(None, Some(&explicit), HUB_SEARCH_SUMMARY, HUB_SEARCH_FULL).unwrap();
1923 let v = project_fields(r.to_value_with_optional_docstring(false), &fields);
1924 let obj = v.as_object().expect("object");
1925 assert_eq!(obj.len(), 1, "bogus must not appear");
1926 assert!(obj.contains_key("name"));
1927 }
1928
1929 #[test]
1930 fn hub_search_invalid_verbose_errors() {
1931 let err =
1932 resolve_fields(Some("fat"), None, HUB_SEARCH_SUMMARY, HUB_SEARCH_FULL).unwrap_err();
1933 assert!(
1934 err.contains("fat"),
1935 "error must mention the offending value"
1936 );
1937 }
1938
1939 fn classify(r: &SearchResult, query: &str) -> Option<bool> {
1948 let ql = query.to_lowercase();
1949 if query.is_empty() {
1950 return None;
1951 }
1952 let empty = String::new();
1953 let pkg = &r.entity;
1954 let other_hit = pkg.name.to_lowercase().contains(&ql)
1955 || pkg
1956 .description
1957 .as_ref()
1958 .unwrap_or(&empty)
1959 .to_lowercase()
1960 .contains(&ql)
1961 || pkg
1962 .category
1963 .as_ref()
1964 .unwrap_or(&empty)
1965 .to_lowercase()
1966 .contains(&ql);
1967 let doc_hit = pkg
1968 .docstring
1969 .as_ref()
1970 .unwrap_or(&empty)
1971 .to_lowercase()
1972 .contains(&ql);
1973 if !other_hit && doc_hit {
1974 Some(true)
1975 } else {
1976 None
1977 }
1978 }
1979
1980 #[test]
1981 fn docstring_matched_true_when_only_docstring_hits() {
1982 let r = sample_search_result();
1983 assert_eq!(classify(&r, "thompson"), Some(true));
1985 }
1986
1987 #[test]
1988 fn docstring_matched_none_when_name_also_hits() {
1989 let r = sample_search_result();
1990 assert_eq!(classify(&r, "cascade"), None);
1992 }
1993
1994 #[test]
1995 fn docstring_matched_none_when_description_hits() {
1996 let r = sample_search_result();
1997 assert_eq!(classify(&r, "routing"), None);
1999 }
2000
2001 #[test]
2002 fn docstring_matched_none_when_query_empty() {
2003 let r = sample_search_result();
2004 assert_eq!(classify(&r, ""), None);
2005 }
2006
2007 fn build_filter_map(
2015 category: Option<&str>,
2016 installed_only: Option<bool>,
2017 explicit: Option<HashMap<String, serde_json::Value>>,
2018 ) -> HashMap<String, serde_json::Value> {
2019 let mut filter_map = explicit.unwrap_or_default();
2020 if let Some(cat) = category {
2021 filter_map
2022 .entry("category".to_string())
2023 .or_insert_with(|| serde_json::Value::String(cat.to_string()));
2024 }
2025 if let Some(only) = installed_only {
2026 if only {
2027 filter_map
2028 .entry("installed".to_string())
2029 .or_insert(serde_json::Value::Bool(true));
2030 }
2031 }
2032 filter_map
2033 }
2034
2035 #[test]
2036 fn filter_by_category_via_legacy_param() {
2037 let m = build_filter_map(Some("reasoning"), None, None);
2038 assert_eq!(
2039 m.get("category"),
2040 Some(&serde_json::Value::String("reasoning".to_string()))
2041 );
2042 }
2043
2044 #[test]
2045 fn filter_by_installed_only_via_legacy_param() {
2046 let m = build_filter_map(None, Some(true), None);
2047 assert_eq!(m.get("installed"), Some(&serde_json::Value::Bool(true)));
2048 }
2049
2050 #[test]
2051 fn filter_installed_only_false_is_noop() {
2052 let m = build_filter_map(None, Some(false), None);
2053 assert!(
2054 !m.contains_key("installed"),
2055 "installed_only=false should not fold in"
2056 );
2057 }
2058
2059 #[test]
2060 fn filter_beats_legacy_param_on_conflict() {
2061 let mut explicit = HashMap::new();
2064 explicit.insert(
2065 "category".to_string(),
2066 serde_json::Value::String("meta".to_string()),
2067 );
2068 let m = build_filter_map(Some("reasoning"), None, Some(explicit));
2069 assert_eq!(
2070 m.get("category"),
2071 Some(&serde_json::Value::String("meta".to_string()))
2072 );
2073 }
2074
2075 #[test]
2076 fn filter_merges_legacy_when_no_conflict() {
2077 let mut explicit = HashMap::new();
2080 explicit.insert("installed".to_string(), serde_json::Value::Bool(true));
2081 let m = build_filter_map(Some("reasoning"), None, Some(explicit));
2082 assert_eq!(
2083 m.get("category"),
2084 Some(&serde_json::Value::String("reasoning".to_string()))
2085 );
2086 assert_eq!(m.get("installed"), Some(&serde_json::Value::Bool(true)));
2087 }
2088
2089 #[test]
2092 fn load_registries_missing_file_returns_default() {
2093 let tmp = tempfile::tempdir().unwrap();
2094 let app_dir = AppDir::new(tmp.path().to_path_buf());
2095 let result = load_registries(&app_dir);
2097 assert!(result.is_ok(), "missing file should be Ok: {result:?}");
2098 assert!(result.unwrap().registries.is_empty());
2099 }
2100
2101 #[test]
2102 fn load_registries_corrupt_json_returns_err() {
2103 let tmp = tempfile::tempdir().unwrap();
2104 let app_dir = AppDir::new(tmp.path().to_path_buf());
2105 let path = app_dir.hub_registries_json();
2107 std::fs::create_dir_all(path.parent().unwrap()).unwrap();
2108 std::fs::write(&path, b"not valid json {{{").unwrap();
2109 let result = load_registries(&app_dir);
2110 assert!(result.is_err(), "corrupt JSON must propagate Err");
2111 let msg = result.unwrap_err().to_string();
2112 assert!(
2113 msg.contains("parse"),
2114 "error message should mention parse: {msg}"
2115 );
2116 }
2117
2118 #[test]
2119 fn load_registries_valid_file_deserializes() {
2120 let tmp = tempfile::tempdir().unwrap();
2121 let app_dir = AppDir::new(tmp.path().to_path_buf());
2122 let path = app_dir.hub_registries_json();
2123 std::fs::create_dir_all(path.parent().unwrap()).unwrap();
2124 let content = r#"{"registries":[{"source":"https://github.com/user/repo","origin":"pkg_install","added_at":"2026-01-01T00:00:00Z"}]}"#;
2125 std::fs::write(&path, content).unwrap();
2126 let result = load_registries(&app_dir);
2127 assert!(result.is_ok(), "valid JSON must parse Ok: {result:?}");
2128 let reg = result.unwrap();
2129 assert_eq!(reg.registries.len(), 1);
2130 assert_eq!(reg.registries[0].source, "https://github.com/user/repo");
2131 }
2132
2133 #[test]
2136 fn default_sort_is_minus_installed_name() {
2137 let keys = parse_sort("-installed,name").unwrap();
2138 assert_eq!(keys.len(), 2);
2139 assert_eq!(keys[0].key, "installed");
2140 assert!(keys[0].desc, "installed must sort desc (true first)");
2141 assert_eq!(keys[1].key, "name");
2142 assert!(!keys[1].desc);
2143
2144 let mut items = vec![
2146 serde_json::json!({"installed": false, "name": "zeta"}),
2147 serde_json::json!({"installed": true, "name": "mu"}),
2148 serde_json::json!({"installed": false, "name": "alpha"}),
2149 serde_json::json!({"installed": true, "name": "beta"}),
2150 ];
2151 apply_sort_by_value(&mut items, &keys);
2152 let names: Vec<&str> = items
2153 .iter()
2154 .map(|v| v.get("name").and_then(|x| x.as_str()).unwrap_or(""))
2155 .collect();
2156 assert_eq!(names, vec!["beta", "mu", "alpha", "zeta"]);
2157 }
2158
2159 #[test]
2164 fn collection_url_from_config_absent_returns_ok_none() {
2165 let tmp = tempfile::tempdir().unwrap();
2166 let app_dir = AppDir::new(tmp.path().to_path_buf());
2167 let result = collection_url_from_config(&app_dir);
2169 assert!(
2170 matches!(result, Ok(None)),
2171 "absent config.toml must return Ok(None), got {result:?}"
2172 );
2173 }
2174
2175 #[test]
2176 fn collection_url_from_config_corrupt_toml_returns_err() {
2177 let tmp = tempfile::tempdir().unwrap();
2178 let app_dir = AppDir::new(tmp.path().to_path_buf());
2179 let path = app_dir.config_toml();
2180 std::fs::create_dir_all(path.parent().unwrap()).unwrap();
2181 std::fs::write(&path, b"[hub\ncollection_url = broken{{{{").unwrap();
2182 let result = collection_url_from_config(&app_dir);
2183 assert!(
2184 result.is_err(),
2185 "corrupt TOML must return Err, got {result:?}"
2186 );
2187 }
2188
2189 #[test]
2190 fn collection_url_from_config_valid_returns_url() {
2191 let tmp = tempfile::tempdir().unwrap();
2192 let app_dir = AppDir::new(tmp.path().to_path_buf());
2193 let path = app_dir.config_toml();
2194 std::fs::create_dir_all(path.parent().unwrap()).unwrap();
2195 std::fs::write(
2196 &path,
2197 b"[hub]\ncollection_url = \"https://example.com/hub_index.json\"\n",
2198 )
2199 .unwrap();
2200 let result = collection_url_from_config(&app_dir);
2201 assert_eq!(
2202 result.unwrap(),
2203 Some("https://example.com/hub_index.json".to_string())
2204 );
2205 }
2206
2207 #[test]
2208 fn collection_url_from_config_no_hub_section_returns_none() {
2209 let tmp = tempfile::tempdir().unwrap();
2210 let app_dir = AppDir::new(tmp.path().to_path_buf());
2211 let path = app_dir.config_toml();
2212 std::fs::create_dir_all(path.parent().unwrap()).unwrap();
2213 std::fs::write(&path, b"[some_other_section]\nfoo = \"bar\"\n").unwrap();
2214 let result = collection_url_from_config(&app_dir);
2215 assert!(
2216 matches!(result, Ok(None)),
2217 "config without [hub] must return Ok(None), got {result:?}"
2218 );
2219 }
2220
2221 #[test]
2224 fn load_cached_absent_returns_ok_none() {
2225 let tmp = tempfile::tempdir().unwrap();
2226 let app_dir = AppDir::new(tmp.path().to_path_buf());
2227 let result = load_cached(&app_dir, "https://example.com/index.json");
2228 assert!(
2229 matches!(result, Ok(None)),
2230 "absent cache file must return Ok(None), got {result:?}"
2231 );
2232 }
2233
2234 #[test]
2235 fn load_cached_corrupt_json_within_ttl_returns_err() {
2236 let tmp = tempfile::tempdir().unwrap();
2237 let app_dir = AppDir::new(tmp.path().to_path_buf());
2238 let url = "https://example.com/index.json";
2239 let dir = cache_dir(&app_dir);
2240 std::fs::create_dir_all(&dir).unwrap();
2241 let path = dir.join(format!("{}.json", cache_key(url)));
2242 std::fs::write(&path, b"not valid json {{{{").unwrap();
2243 let result = load_cached(&app_dir, url);
2245 assert!(
2246 result.is_err(),
2247 "corrupt JSON within TTL must return Err, got {result:?}"
2248 );
2249 }
2250
2251 #[test]
2252 fn load_cached_valid_json_within_ttl_returns_index() {
2253 let tmp = tempfile::tempdir().unwrap();
2254 let app_dir = AppDir::new(tmp.path().to_path_buf());
2255 let url = "https://example.com/index.json";
2256 let dir = cache_dir(&app_dir);
2257 std::fs::create_dir_all(&dir).unwrap();
2258 let path = dir.join(format!("{}.json", cache_key(url)));
2259 let index_json = r#"{"schema_version":"hub_index/v0","updated_at":"2026-01-01T00:00:00Z","packages":[]}"#;
2260 std::fs::write(&path, index_json).unwrap();
2261 let result = load_cached(&app_dir, url);
2262 assert!(
2263 matches!(result, Ok(Some(_))),
2264 "valid JSON within TTL must return Ok(Some(_)), got {result:?}"
2265 );
2266 }
2267
2268 fn backdate_file(path: &std::path::Path, secs: u64) {
2270 let past = std::time::SystemTime::now() - std::time::Duration::from_secs(secs);
2271 let times = std::fs::FileTimes::new()
2272 .set_accessed(past)
2273 .set_modified(past);
2274 let f = std::fs::OpenOptions::new()
2275 .write(true)
2276 .open(path)
2277 .expect("open for backdate");
2278 f.set_times(times).expect("set_times");
2279 }
2280
2281 #[test]
2283 fn load_cached_full_stale_file_returns_stale_variant() {
2284 let tmp = tempfile::tempdir().unwrap();
2285 let app_dir = AppDir::new(tmp.path().to_path_buf());
2286 let url = "https://stale.example.com/index.json";
2287 write_cache_for_url(&app_dir, url, &make_index(vec![("stale_pkg", "0.1.0")]));
2289 let path = cache_dir(&app_dir).join(format!("{}.json", cache_key(url)));
2291 backdate_file(&path, CACHE_TTL_SECS * 2);
2292 let result = load_cached_full(&app_dir, url);
2293 assert!(
2294 matches!(result, CacheLookup::Stale(_)),
2295 "backdated cache must return Stale variant"
2296 );
2297 }
2298
2299 #[tokio::test]
2301 async fn aggregate_index_stale_cache_returns_data_and_warning() {
2302 let tmp = tempfile::tempdir().unwrap();
2303 let app_dir_root = tmp.path().to_path_buf();
2304 let app_dir = AppDir::new(app_dir_root.clone());
2305 let url = "https://stale-agg.example.com/index.json";
2306
2307 write_cache_for_url(&app_dir, url, &make_index(vec![("stale_pkg", "0.1.0")]));
2309 let cache_path = cache_dir(&app_dir).join(format!("{}.json", cache_key(url)));
2311 backdate_file(&cache_path, CACHE_TTL_SECS * 2);
2312
2313 let reg_path = app_dir.hub_registries_json();
2315 std::fs::create_dir_all(reg_path.parent().unwrap()).unwrap();
2316 let reg_json = serde_json::json!({
2317 "registries": [{"source": url, "origin": "pkg_install", "added_at": "2026-01-01T00:00:00Z"}]
2318 });
2319 std::fs::write(®_path, reg_json.to_string()).unwrap();
2320
2321 let svc = super::super::test_support::make_app_service_at(app_dir_root).await;
2322 let (index, warnings) = AppService::aggregate_index(&svc).unwrap();
2323
2324 assert!(
2326 index.packages.iter().any(|p| p.entity.name == "stale_pkg"),
2327 "stale package must be included in aggregate, got: {:?}",
2328 index
2329 .packages
2330 .iter()
2331 .map(|p| &p.entity.name)
2332 .collect::<Vec<_>>()
2333 );
2334 assert!(
2336 warnings
2337 .iter()
2338 .any(|w| w.contains("stale") && w.contains(url)),
2339 "stale cache must emit a warning mentioning the URL, got: {warnings:?}"
2340 );
2341 }
2342
2343 #[test]
2346 fn count_evals_for_pkg_absent_dir_returns_zero_no_warnings() {
2347 let tmp = tempfile::tempdir().unwrap();
2348 let app_dir = AppDir::new(tmp.path().to_path_buf());
2349 let mut warnings: Vec<String> = Vec::new();
2350 let count = count_evals_for_pkg(&app_dir, "cot", &mut warnings);
2351 assert_eq!(count, 0, "absent evals dir must return 0");
2352 assert!(
2353 warnings.is_empty(),
2354 "absent evals dir must produce no warnings, got {warnings:?}"
2355 );
2356 }
2357
2358 #[test]
2359 fn count_evals_for_pkg_corrupt_meta_surfaces_warning() {
2360 let tmp = tempfile::tempdir().unwrap();
2361 let app_dir = AppDir::new(tmp.path().to_path_buf());
2362 let evals_dir = app_dir.evals_dir();
2363 std::fs::create_dir_all(&evals_dir).unwrap();
2364
2365 std::fs::write(evals_dir.join("cot_9999.json"), b"{}").unwrap();
2367 std::fs::write(evals_dir.join("cot_9999.meta.json"), b"not json {{{{").unwrap();
2369
2370 let mut warnings: Vec<String> = Vec::new();
2371 let _count = count_evals_for_pkg(&app_dir, "cot", &mut warnings);
2372 assert!(
2373 !warnings.is_empty(),
2374 "corrupt meta.json must produce at least one warning, got {warnings:?}"
2375 );
2376 assert!(
2377 warnings[0].contains("parse"),
2378 "warning must mention parse: {}",
2379 warnings[0]
2380 );
2381 }
2382
2383 #[test]
2384 fn count_evals_for_pkg_valid_meta_counts_correctly() {
2385 let tmp = tempfile::tempdir().unwrap();
2386 let app_dir = AppDir::new(tmp.path().to_path_buf());
2387 let evals_dir = app_dir.evals_dir();
2388 std::fs::create_dir_all(&evals_dir).unwrap();
2389
2390 let meta = r#"{"eval_id":"cot_1","strategy":"cot","timestamp":1}"#;
2392 std::fs::write(evals_dir.join("cot_1.json"), b"{}").unwrap();
2393 std::fs::write(evals_dir.join("cot_1.meta.json"), meta).unwrap();
2394
2395 let mut warnings: Vec<String> = Vec::new();
2396 let count = count_evals_for_pkg(&app_dir, "cot", &mut warnings);
2397 assert_eq!(count, 1, "should count 1 valid eval");
2398 assert!(warnings.is_empty(), "no warnings expected: {warnings:?}");
2399 }
2400
2401 fn write_cache_for_url(app_dir: &AppDir, url: &str, index: &HubIndex) {
2405 let dir = cache_dir(app_dir);
2406 std::fs::create_dir_all(&dir).unwrap();
2407 let path = dir.join(format!("{}.json", cache_key(url)));
2408 std::fs::write(&path, serde_json::to_string_pretty(index).unwrap()).unwrap();
2410 }
2411
2412 fn make_index(packages: Vec<(&str, &str)>) -> HubIndex {
2413 HubIndex {
2414 schema_version: "hub_index/v0".into(),
2415 updated_at: String::new(),
2416 packages: packages
2417 .into_iter()
2418 .map(|(name, version)| IndexEntry {
2419 entity: PkgEntity {
2420 name: name.to_string(),
2421 version: Some(version.to_string()),
2422 description: None,
2423 category: None,
2424 docstring: None,
2425 tags: None,
2426 pkg_type: None,
2427 type_source: None,
2428 },
2429 source: PackageSource::Unknown,
2430 card_count: 0,
2431 best_card: None,
2432 })
2433 .collect(),
2434 }
2435 }
2436
2437 #[test]
2439 fn aggregate_index_empty_sources_returns_empty() {
2440 let tmp = tempfile::tempdir().unwrap();
2441 let app_dir = AppDir::new(tmp.path().to_path_buf());
2442 let (index, warnings) = {
2446 let mut w: Vec<String> = Vec::new();
2449 let urls = discover_index_urls(&app_dir, &mut w).unwrap();
2450 let mut packages: Vec<IndexEntry> = Vec::new();
2451 let mut seen: std::collections::HashSet<String> = std::collections::HashSet::new();
2452 for url in &urls {
2453 if let Ok(Some(idx)) = load_cached(&app_dir, url) {
2454 for e in idx.packages {
2455 if seen.insert(e.entity.name.clone()) {
2456 packages.push(e);
2457 }
2458 }
2459 }
2460 }
2461 (
2462 HubIndex {
2463 schema_version: "hub_index/v0".into(),
2464 updated_at: String::new(),
2465 packages,
2466 },
2467 w,
2468 )
2469 };
2470 assert!(
2471 index.packages.is_empty(),
2472 "no cached sources should produce empty packages"
2473 );
2474 assert!(warnings.is_empty(), "no warnings expected for cache misses");
2475 }
2476
2477 #[test]
2479 fn aggregate_index_one_source_returns_packages() {
2480 let tmp = tempfile::tempdir().unwrap();
2481 let app_dir = AppDir::new(tmp.path().to_path_buf());
2482 let url = "https://example.com/test_index.json";
2483 let source_index = make_index(vec![("cot", "0.1.0"), ("ucb", "0.2.0")]);
2484 write_cache_for_url(&app_dir, url, &source_index);
2485
2486 let reg_path = app_dir.hub_registries_json();
2488 std::fs::create_dir_all(reg_path.parent().unwrap()).unwrap();
2489 let reg_json = serde_json::json!({
2490 "registries": [{"source": url, "origin": "pkg_install", "added_at": "2026-01-01T00:00:00Z"}]
2491 });
2492 std::fs::write(®_path, reg_json.to_string()).unwrap();
2493
2494 let mut warnings: Vec<String> = Vec::new();
2495 let urls = discover_index_urls(&app_dir, &mut warnings).unwrap();
2496 let mut packages: Vec<IndexEntry> = Vec::new();
2497 let mut seen: std::collections::HashSet<String> = std::collections::HashSet::new();
2498 for u in &urls {
2499 if let Ok(Some(idx)) = load_cached(&app_dir, u) {
2500 for e in idx.packages {
2501 if seen.insert(e.entity.name.clone()) {
2502 packages.push(e);
2503 }
2504 }
2505 }
2506 }
2507
2508 assert!(
2509 packages.iter().any(|p| p.entity.name == "cot"),
2510 "cot expected"
2511 );
2512 assert!(
2513 packages.iter().any(|p| p.entity.name == "ucb"),
2514 "ucb expected"
2515 );
2516 }
2517
2518 #[test]
2520 fn aggregate_index_deduplicate_by_name_first_wins() {
2521 let tmp = tempfile::tempdir().unwrap();
2522 let app_dir = AppDir::new(tmp.path().to_path_buf());
2523 let url_a = "https://a.example.com/index.json";
2524 let url_b = "https://b.example.com/index.json";
2525
2526 let idx_a = make_index(vec![("cot", "1.0.0")]);
2528 let idx_b = make_index(vec![("cot", "2.0.0"), ("ucb", "0.1.0")]);
2529 write_cache_for_url(&app_dir, url_a, &idx_a);
2530 write_cache_for_url(&app_dir, url_b, &idx_b);
2531
2532 let reg_path = app_dir.hub_registries_json();
2533 std::fs::create_dir_all(reg_path.parent().unwrap()).unwrap();
2534 let reg_json = serde_json::json!({
2535 "registries": [
2536 {"source": url_a, "origin": "pkg_install", "added_at": "2026-01-01T00:00:00Z"},
2537 {"source": url_b, "origin": "pkg_install", "added_at": "2026-01-01T00:00:00Z"}
2538 ]
2539 });
2540 std::fs::write(®_path, reg_json.to_string()).unwrap();
2541
2542 let mut warnings: Vec<String> = Vec::new();
2543 let urls = {
2544 let mut raw = discover_index_urls(&app_dir, &mut warnings).unwrap();
2545 raw.retain(|u| u == url_a || u == url_b);
2547 raw
2548 };
2549
2550 let mut packages: Vec<IndexEntry> = Vec::new();
2551 let mut seen: std::collections::HashSet<String> = std::collections::HashSet::new();
2552 for u in &urls {
2553 if let Ok(Some(idx)) = load_cached(&app_dir, u) {
2554 for e in idx.packages {
2555 if seen.insert(e.entity.name.clone()) {
2556 packages.push(e);
2557 }
2558 }
2559 }
2560 }
2561
2562 let cot_count = packages.iter().filter(|p| p.entity.name == "cot").count();
2563 assert_eq!(cot_count, 1, "dedup: cot must appear exactly once");
2564 let ucb_count = packages.iter().filter(|p| p.entity.name == "ucb").count();
2565 assert_eq!(ucb_count, 1, "ucb from second source must appear");
2566 }
2567
2568 #[test]
2570 fn aggregate_index_corrupt_cache_collects_warning() {
2571 let tmp = tempfile::tempdir().unwrap();
2572 let app_dir = AppDir::new(tmp.path().to_path_buf());
2573 let url_corrupt = "https://corrupt.example.com/index.json";
2574
2575 let dir = cache_dir(&app_dir);
2577 std::fs::create_dir_all(&dir).unwrap();
2578 let path = dir.join(format!("{}.json", cache_key(url_corrupt)));
2579 std::fs::write(&path, b"{{{{ not valid json").unwrap();
2580
2581 let reg_path = app_dir.hub_registries_json();
2582 std::fs::create_dir_all(reg_path.parent().unwrap()).unwrap();
2583 let reg_json = serde_json::json!({
2584 "registries": [{"source": url_corrupt, "origin": "pkg_install", "added_at": "2026-01-01T00:00:00Z"}]
2585 });
2586 std::fs::write(®_path, reg_json.to_string()).unwrap();
2587
2588 let mut warnings: Vec<String> = Vec::new();
2589 let urls = discover_index_urls(&app_dir, &mut warnings).unwrap();
2590 let mut packages: Vec<IndexEntry> = Vec::new();
2591 let mut seen: std::collections::HashSet<String> = std::collections::HashSet::new();
2592 let mut extra_warnings: Vec<String> = Vec::new();
2593 for u in &urls {
2594 match load_cached(&app_dir, u) {
2595 Ok(Some(idx)) => {
2596 for e in idx.packages {
2597 if seen.insert(e.entity.name.clone()) {
2598 packages.push(e);
2599 }
2600 }
2601 }
2602 Ok(None) => {}
2603 Err(e) => extra_warnings.push(format!("hub cache read failed for {u}: {e}")),
2604 }
2605 }
2606
2607 assert!(
2608 !extra_warnings.is_empty(),
2609 "corrupt cache must produce a warning"
2610 );
2611 assert!(
2612 extra_warnings[0].contains("hub cache read failed"),
2613 "warning text mismatch: {}",
2614 extra_warnings[0]
2615 );
2616 assert!(packages.is_empty(), "no packages from corrupt source");
2617 }
2618
2619 #[tokio::test]
2622 async fn aggregate_index_registry_failure_returns_ok_with_warning() {
2623 let tmp = tempfile::tempdir().unwrap();
2624 let app_dir_root = tmp.path().to_path_buf();
2625
2626 let reg_path = AppDir::new(app_dir_root.clone()).hub_registries_json();
2628 std::fs::create_dir_all(reg_path.parent().unwrap()).unwrap();
2629 std::fs::write(®_path, b"{{{{ not valid json").unwrap();
2630
2631 let svc = super::super::test_support::make_app_service_at(app_dir_root).await;
2637 let result = AppService::aggregate_index(&svc);
2638 assert!(
2639 result.is_ok(),
2640 "aggregate_index must return Ok even on registry-load failure, got: {result:?}"
2641 );
2642 let (index, warnings) = result.unwrap();
2643 assert!(
2644 index.packages.is_empty(),
2645 "degraded response must have empty packages"
2646 );
2647 assert!(
2648 !warnings.is_empty(),
2649 "registry-load failure must produce a warning"
2650 );
2651 assert!(
2652 warnings
2653 .iter()
2654 .any(|w| w.contains("hub registry discovery failed")),
2655 "warning must mention registry discovery failure, got: {warnings:?}"
2656 );
2657 }
2658}