1use std::collections::{HashMap, HashSet};
77use std::path::PathBuf;
78
79use serde::{Deserialize, Serialize};
80
81use super::manifest;
82use super::resolve::AUTO_INSTALL_SOURCES;
83use super::AppService;
84
85const CACHE_TTL_SECS: u64 = 3600;
89
90const HTTP_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(30);
92
93#[derive(Debug, Clone, Serialize, Deserialize)]
97pub(crate) struct HubIndex {
98 pub schema_version: String,
99 #[serde(default)]
100 pub updated_at: String,
101 #[serde(default)]
102 pub packages: Vec<IndexEntry>,
103}
104
105#[derive(Debug, Clone, Serialize, Deserialize)]
107pub(crate) struct IndexEntry {
108 pub name: String,
109 #[serde(default)]
110 pub version: String,
111 #[serde(default)]
112 pub description: String,
113 #[serde(default)]
114 pub category: String,
115 #[serde(default)]
116 pub source: String,
117 #[serde(default)]
118 pub card_count: usize,
119 #[serde(default)]
120 pub best_card: Option<BestCard>,
121}
122
123#[derive(Debug, Clone, Serialize, Deserialize)]
125pub(crate) struct BestCard {
126 pub card_id: String,
127 #[serde(default)]
128 pub model: String,
129 #[serde(default)]
130 pub pass_rate: f64,
131 #[serde(default)]
132 pub scenario: String,
133}
134
135#[derive(Debug, Clone, Serialize)]
137struct SearchResult {
138 name: String,
139 version: String,
140 description: String,
141 category: String,
142 source: String,
143 installed: bool,
144 card_count: usize,
145 best_card: Option<BestCard>,
146}
147
148#[derive(Debug, Clone, Serialize, Deserialize)]
157pub(crate) struct RegistryEntry {
158 pub source: String,
160 pub origin: String,
162 pub added_at: String,
164}
165
166#[derive(Debug, Clone, Serialize, Deserialize, Default)]
168pub(crate) struct HubRegistries {
169 pub registries: Vec<RegistryEntry>,
170}
171
172fn registries_path() -> Result<PathBuf, String> {
173 let home = dirs::home_dir().ok_or("Cannot determine home directory")?;
174 Ok(home.join(".algocline").join("hub_registries.json"))
175}
176
177fn load_registries() -> HubRegistries {
179 let path = match registries_path() {
180 Ok(p) => p,
181 Err(_) => return HubRegistries::default(),
182 };
183 if !path.exists() {
184 return HubRegistries::default();
185 }
186 std::fs::read_to_string(&path)
187 .ok()
188 .and_then(|c| serde_json::from_str(&c).ok())
189 .unwrap_or_default()
190}
191
192pub(crate) fn register_source(source: &str, origin: &str) {
199 let normalized = source.trim_end_matches('/').to_string();
200 if normalized.is_empty() {
201 return;
202 }
203 if normalized.starts_with('/') || normalized.starts_with('.') {
205 return;
206 }
207
208 let path = match registries_path() {
209 Ok(p) => p,
210 Err(_) => return,
211 };
212 if let Some(parent) = path.parent() {
213 let _ = std::fs::create_dir_all(parent);
214 }
215
216 let mut reg = load_registries();
218
219 if reg
221 .registries
222 .iter()
223 .any(|e| e.source.trim_end_matches('/') == normalized)
224 {
225 return;
226 }
227
228 reg.registries.push(RegistryEntry {
229 source: normalized,
230 origin: origin.to_string(),
231 added_at: manifest::now_iso8601(),
232 });
233
234 match serde_json::to_string_pretty(®) {
236 Ok(json) => {
237 let tmp_path = path.with_extension("json.tmp");
238 if let Err(e) = std::fs::write(&tmp_path, &json) {
239 tracing::warn!("failed to write hub registries tmp: {e}");
240 return;
241 }
242 if let Err(e) = std::fs::rename(&tmp_path, &path) {
243 tracing::warn!("failed to rename hub registries: {e}");
244 let _ = std::fs::remove_file(&tmp_path);
246 }
247 }
248 Err(e) => tracing::warn!("failed to serialize hub registries: {e}"),
249 }
250}
251
252fn collection_url_from_config() -> Option<String> {
264 let home = dirs::home_dir()?;
265 let path = home.join(".algocline").join("config.toml");
266 let content = std::fs::read_to_string(&path).ok()?;
267 let doc: toml_edit::DocumentMut = content.parse().ok()?;
268 let url = doc
269 .get("hub")?
270 .get("collection_url")?
271 .as_str()?
272 .trim()
273 .to_string();
274 if url.is_empty() {
275 None
276 } else {
277 Some(url)
278 }
279}
280
281fn repo_to_index_url(repo_url: &str) -> Option<String> {
296 let trimmed = repo_url.trim_end_matches('/').trim_end_matches(".git");
297 if let Some(path) = trimmed.strip_prefix("https://github.com/") {
298 let parts: Vec<&str> = path.splitn(3, '/').collect();
300 if parts.len() >= 2 {
301 return Some(format!(
302 "https://raw.githubusercontent.com/{}/{}/main/hub_index.json",
303 parts[0], parts[1]
304 ));
305 }
306 }
307 if trimmed.ends_with(".json") {
309 Some(trimmed.to_string())
310 } else {
311 None
312 }
313}
314
315fn discover_index_urls() -> Vec<String> {
317 let mut index_urls: Vec<String> = Vec::new();
318
319 if let Some(url) = collection_url_from_config() {
321 index_urls.push(url);
322 }
323
324 let mut repo_urls: HashSet<String> = HashSet::new();
325
326 let reg = load_registries();
328 for entry in ®.registries {
329 let normalized = entry.source.trim_end_matches('/').to_string();
330 if !normalized.is_empty() {
331 repo_urls.insert(normalized);
332 }
333 }
334
335 if let Ok(m) = manifest::load_manifest() {
337 for entry in m.packages.values() {
338 let normalized = entry.source.trim_end_matches('/').to_string();
339 if !normalized.is_empty() && !normalized.starts_with('/') {
340 repo_urls.insert(normalized);
341 }
342 }
343 }
344
345 for url in AUTO_INSTALL_SOURCES {
347 repo_urls.insert(url.to_string());
348 }
349
350 let existing: HashSet<String> = index_urls.iter().cloned().collect();
352 let mut derived: Vec<String> = repo_urls
353 .iter()
354 .filter_map(|url| repo_to_index_url(url))
355 .filter(|url| !existing.contains(url))
356 .collect();
357 derived.sort();
358 derived.dedup();
359 index_urls.extend(derived);
360
361 index_urls
362}
363
364fn cache_dir() -> Result<PathBuf, String> {
372 let home = dirs::home_dir().ok_or("Cannot determine home directory")?;
373 Ok(home.join(".algocline").join("hub_cache"))
374}
375
376fn cache_key(url: &str) -> String {
377 let mut h: u64 = 0xcbf2_9ce4_8422_2325; for b in url.as_bytes() {
381 h ^= *b as u64;
382 h = h.wrapping_mul(0x0100_0000_01b3); }
384 format!("{h:016x}")
385}
386
387fn load_cached(url: &str) -> Option<HubIndex> {
389 let dir = cache_dir().ok()?;
390 let path = dir.join(format!("{}.json", cache_key(url)));
391 if !path.exists() {
392 return None;
393 }
394 let metadata = std::fs::metadata(&path).ok()?;
395 let age = metadata.modified().ok()?.elapsed().ok()?;
396 if age.as_secs() > CACHE_TTL_SECS {
397 return None;
398 }
399 let content = std::fs::read_to_string(&path).ok()?;
400 serde_json::from_str(&content).ok()
401}
402
403fn save_cached(url: &str, index: &HubIndex) {
405 let dir = match cache_dir() {
406 Ok(d) => d,
407 Err(e) => {
408 tracing::warn!("hub cache dir unavailable: {e}");
409 return;
410 }
411 };
412 if let Err(e) = std::fs::create_dir_all(&dir) {
413 tracing::warn!("failed to create hub cache dir: {e}");
414 return;
415 }
416 let path = dir.join(format!("{}.json", cache_key(url)));
417 match serde_json::to_string_pretty(index) {
418 Ok(json) => {
419 if let Err(e) = std::fs::write(&path, json) {
420 tracing::warn!("failed to write hub cache {}: {e}", path.display());
421 }
422 }
423 Err(e) => tracing::warn!("failed to serialize hub cache: {e}"),
424 }
425}
426
427fn fetch_one(url: &str) -> Result<HubIndex, String> {
431 if let Some(cached) = load_cached(url) {
432 return Ok(cached);
433 }
434
435 let agent = ureq::Agent::new_with_config(
436 ureq::config::Config::builder()
437 .timeout_global(Some(HTTP_TIMEOUT))
438 .build(),
439 );
440 let body: String = agent
441 .get(url)
442 .call()
443 .map_err(|e| format!("Failed to fetch {url}: {e}"))?
444 .body_mut()
445 .read_to_string()
446 .map_err(|e| format!("Failed to read response from {url}: {e}"))?;
447
448 let index: HubIndex = serde_json::from_str(&body)
449 .map_err(|e| format!("Failed to parse index from {url}: {e}"))?;
450
451 save_cached(url, &index);
452 Ok(index)
453}
454
455fn fetch_remote_indices() -> (HubIndex, Vec<String>) {
458 let urls = discover_index_urls();
459 let mut all_packages: Vec<IndexEntry> = Vec::new();
460 let mut seen_names: HashSet<String> = HashSet::new();
461 let mut warnings: Vec<String> = Vec::new();
462
463 for url in &urls {
464 match fetch_one(url) {
465 Ok(index) => {
466 for entry in index.packages {
467 if seen_names.insert(entry.name.clone()) {
468 all_packages.push(entry);
469 }
470 }
472 }
473 Err(e) => {
474 warnings.push(e);
475 }
476 }
477 }
478
479 if all_packages.is_empty() && !warnings.is_empty() {
480 warnings.insert(
481 0,
482 "all remote indices unavailable, showing local packages only".to_string(),
483 );
484 }
485
486 let merged = HubIndex {
487 schema_version: "hub_index/v0".into(),
488 updated_at: String::new(),
489 packages: all_packages,
490 };
491 (merged, warnings)
492}
493
494fn installed_packages() -> HashMap<String, Option<String>> {
499 let mut map = HashMap::new();
500
501 if let Ok(m) = manifest::load_manifest() {
503 for (name, entry) in &m.packages {
504 map.insert(name.clone(), entry.version.clone());
505 }
506 }
507
508 if let Some(home) = dirs::home_dir() {
510 let pkg_dir = home.join(".algocline").join("packages");
511 if let Ok(entries) = std::fs::read_dir(&pkg_dir) {
512 for entry in entries.flatten() {
513 if entry.path().is_dir() {
514 if let Some(name) = entry.file_name().to_str() {
515 map.entry(name.to_string()).or_insert(None);
516 }
517 }
518 }
519 }
520 }
521
522 map
523}
524
525fn local_card_counts() -> HashMap<String, usize> {
527 let mut map = HashMap::new();
528 let home = match dirs::home_dir() {
529 Some(h) => h,
530 None => return map,
531 };
532 let cards_dir = home.join(".algocline").join("cards");
533 let entries = match std::fs::read_dir(&cards_dir) {
534 Ok(e) => e,
535 Err(_) => return map,
536 };
537 for entry in entries.flatten() {
538 if !entry.path().is_dir() {
539 continue;
540 }
541 let pkg = match entry.file_name().to_str() {
542 Some(n) => n.to_string(),
543 None => continue,
544 };
545 let count = std::fs::read_dir(entry.path())
546 .map(|es| {
547 es.flatten()
548 .filter(|e| e.path().extension().is_some_and(|ext| ext == "toml"))
549 .count()
550 })
551 .unwrap_or(0);
552 if count > 0 {
553 map.insert(pkg, count);
554 }
555 }
556 map
557}
558
559fn count_evals_for_pkg(pkg: &str) -> usize {
564 let home = match dirs::home_dir() {
565 Some(h) => h,
566 None => return 0,
567 };
568 let evals_dir = home.join(".algocline").join("evals");
569 let entries = match std::fs::read_dir(&evals_dir) {
570 Ok(e) => e,
571 Err(_) => return 0,
572 };
573
574 let mut meta_stems: HashSet<String> = HashSet::new();
577 let mut meta_matches: usize = 0;
578 let mut non_meta_paths: Vec<(PathBuf, String)> = Vec::new(); for entry in entries.flatten() {
581 let path = entry.path();
582 let name = match path.file_name().and_then(|n| n.to_str()) {
583 Some(n) => n.to_string(),
584 None => continue,
585 };
586
587 if name.ends_with(".meta.json") {
588 let stem = name.trim_end_matches(".meta.json").to_string();
589 meta_stems.insert(stem);
590 if let Ok(content) = std::fs::read_to_string(&path) {
591 if let Ok(val) = serde_json::from_str::<serde_json::Value>(&content) {
592 if val.get("strategy").and_then(|s| s.as_str()) == Some(pkg) {
593 meta_matches += 1;
594 }
595 }
596 }
597 continue;
598 }
599
600 if !name.ends_with(".json") || name.starts_with("compare_") {
602 continue;
603 }
604
605 let stem = path
606 .file_stem()
607 .and_then(|s| s.to_str())
608 .unwrap_or("")
609 .to_string();
610 non_meta_paths.push((path, stem));
611 }
612
613 let fallback_matches = non_meta_paths
615 .iter()
616 .filter(|(_, stem)| !meta_stems.contains(stem))
617 .filter(|(path, _)| {
618 std::fs::read_to_string(path)
619 .ok()
620 .and_then(|c| serde_json::from_str::<serde_json::Value>(&c).ok())
621 .and_then(|v| v.get("strategy")?.as_str().map(|s| s == pkg))
622 .unwrap_or(false)
623 })
624 .count();
625
626 meta_matches + fallback_matches
627}
628
629fn merge(remote: &HubIndex) -> Vec<SearchResult> {
633 let installed = installed_packages();
634 let card_counts = local_card_counts();
635
636 let mut seen: HashSet<String> = HashSet::new();
637 let mut results: Vec<SearchResult> = Vec::new();
638
639 for entry in &remote.packages {
640 let is_installed = installed.contains_key(&entry.name);
641 let local_cards = card_counts.get(&entry.name).copied().unwrap_or(0);
642
643 seen.insert(entry.name.clone());
644 results.push(SearchResult {
645 name: entry.name.clone(),
646 version: entry.version.clone(),
647 description: entry.description.clone(),
648 category: entry.category.clone(),
649 source: entry.source.clone(),
650 installed: is_installed,
651 card_count: if is_installed && local_cards > entry.card_count {
652 local_cards
653 } else {
654 entry.card_count
655 },
656 best_card: entry.best_card.clone(),
657 });
658 }
659
660 for (name, version) in &installed {
662 if seen.contains(name) {
663 continue;
664 }
665 results.push(SearchResult {
666 name: name.clone(),
667 version: version.clone().unwrap_or_default(),
668 description: String::new(),
669 category: String::new(),
670 source: String::new(),
671 installed: true,
672 card_count: card_counts.get(name).copied().unwrap_or(0),
673 best_card: None,
674 });
675 }
676
677 results
678}
679
680fn matches_query(result: &SearchResult, query: &str) -> bool {
683 let q = query.to_lowercase();
684 result.name.to_lowercase().contains(&q)
685 || result.description.to_lowercase().contains(&q)
686 || result.category.to_lowercase().contains(&q)
687}
688
689fn parse_meta_from_init_lua(path: &std::path::Path) -> Option<(String, String, String, String)> {
701 let content = std::fs::read_to_string(path).ok()?;
702 let mut limit = 2048.min(content.len());
704 while limit > 0 && !content.is_char_boundary(limit) {
705 limit -= 1;
706 }
707 let head = &content[..limit];
708
709 let meta_start = head.find("M.meta")?;
711 let brace_start = head[meta_start..].find('{')? + meta_start;
712
713 let mut depth = 0;
715 let mut brace_end = None;
716 for (i, ch) in head[brace_start..].char_indices() {
717 match ch {
718 '{' => depth += 1,
719 '}' => {
720 depth -= 1;
721 if depth == 0 {
722 brace_end = Some(brace_start + i);
723 break;
724 }
725 }
726 _ => {}
727 }
728 }
729 let brace_end = brace_end?;
730 let block = &head[brace_start + 1..brace_end];
731
732 let extract = |field: &str| -> String {
733 let mut search_from = 0;
739 while let Some(rel) = block[search_from..].find(field) {
740 let pos = search_from + rel;
741 let word_boundary = if pos == 0 {
743 true
744 } else {
745 let prev = block.as_bytes()[pos - 1];
746 !(prev.is_ascii_alphanumeric() || prev == b'_')
747 };
748 if word_boundary {
749 let after = &block[pos + field.len()..];
750 if let Some(q_start_rel) = after.find('"') {
751 let q_start = q_start_rel + 1;
752 if let Some(q_end_rel) = after[q_start..].find('"') {
753 return after[q_start..q_start + q_end_rel].to_string();
754 }
755 }
756 }
757 search_from = pos + field.len();
758 }
759 String::new()
760 };
761
762 let name = extract("name");
763 if name.is_empty() {
764 return None;
765 }
766 Some((
767 name,
768 extract("version"),
769 extract("description"),
770 extract("category"),
771 ))
772}
773
774fn build_index(source_dir: Option<&std::path::Path>) -> HubIndex {
783 let empty = || HubIndex {
784 schema_version: "hub_index/v0".into(),
785 updated_at: super::manifest::now_iso8601(),
786 packages: Vec::new(),
787 };
788
789 let pkg_dir = match source_dir {
790 Some(d) => d.to_path_buf(),
791 None => {
792 let home = match dirs::home_dir() {
793 Some(h) => h,
794 None => return empty(),
795 };
796 home.join(".algocline").join("packages")
797 }
798 };
799
800 let use_local_state = source_dir.is_none();
801 let card_counts = if use_local_state {
802 local_card_counts()
803 } else {
804 HashMap::new()
805 };
806 let manifest = if use_local_state {
807 manifest::load_manifest().unwrap_or_default()
808 } else {
809 manifest::Manifest::default()
810 };
811
812 let mut entries = Vec::new();
813
814 let dir_entries = match std::fs::read_dir(&pkg_dir) {
815 Ok(e) => e,
816 Err(_) => return empty(),
817 };
818
819 for entry in dir_entries.flatten() {
820 if !entry.path().is_dir() {
821 continue;
822 }
823 let dir_name = match entry.file_name().to_str() {
824 Some(n) if !n.starts_with('.') && !n.starts_with('_') => n.to_string(),
825 _ => continue,
826 };
827
828 let init_lua = entry.path().join("init.lua");
829 if !init_lua.exists() {
830 continue;
831 }
832
833 let (name, version, description, category) = parse_meta_from_init_lua(&init_lua)
834 .unwrap_or_else(|| {
835 (
836 dir_name.clone(),
837 String::new(),
838 String::new(),
839 String::new(),
840 )
841 });
842
843 let source = manifest
845 .packages
846 .get(&dir_name)
847 .map(|e| e.source.clone())
848 .unwrap_or_default();
849
850 entries.push(IndexEntry {
851 name,
852 version,
853 description,
854 category,
855 source,
856 card_count: card_counts.get(&dir_name).copied().unwrap_or(0),
857 best_card: None,
858 });
859 }
860
861 entries.sort_by(|a, b| a.name.cmp(&b.name));
862
863 HubIndex {
864 schema_version: "hub_index/v0".into(),
865 updated_at: super::manifest::now_iso8601(),
866 packages: entries,
867 }
868}
869
870impl AppService {
873 pub fn hub_reindex(
882 &self,
883 output_path: Option<&str>,
884 source_dir: Option<&str>,
885 ) -> Result<String, String> {
886 let src = source_dir.map(std::path::Path::new);
887 if let Some(d) = src {
888 if !d.is_dir() {
889 return Err(format!("source_dir '{}' is not a directory", d.display()));
890 }
891 }
892 let index = build_index(src);
893
894 let written_path = if let Some(path) = output_path {
895 let json = serde_json::to_string_pretty(&index)
896 .map_err(|e| format!("Failed to serialize index: {e}"))?;
897 std::fs::write(path, &json)
898 .map_err(|e| format!("Failed to write index to {path}: {e}"))?;
899 Some(path.to_string())
900 } else {
901 None
902 };
903
904 let response = serde_json::json!({
905 "package_count": index.packages.len(),
906 "updated_at": index.updated_at,
907 "output_path": written_path,
908 "source_dir": source_dir,
909 });
910 Ok(response.to_string())
911 }
912
913 pub fn hub_info(&self, pkg: &str) -> Result<String, String> {
918 use algocline_engine::card;
919
920 if pkg.contains("..") || pkg.contains('/') || pkg.contains('\\') {
922 return Err(format!("Invalid package name: '{pkg}'"));
923 }
924
925 let installed = installed_packages();
927 let is_installed = installed.contains_key(pkg);
928
929 let (version, description, category, source) = {
930 let (remote, _) = fetch_remote_indices();
932 if let Some(entry) = remote.packages.iter().find(|e| e.name == pkg) {
933 (
934 entry.version.clone(),
935 entry.description.clone(),
936 entry.category.clone(),
937 entry.source.clone(),
938 )
939 } else if is_installed {
940 let home = dirs::home_dir().ok_or("Cannot determine home directory")?;
942 let init_lua = home
943 .join(".algocline")
944 .join("packages")
945 .join(pkg)
946 .join("init.lua");
947 let meta = parse_meta_from_init_lua(&init_lua);
948 let manifest_source = manifest::load_manifest()
949 .ok()
950 .and_then(|m| m.packages.get(pkg).map(|e| e.source.clone()))
951 .unwrap_or_default();
952 match meta {
953 Some((_, v, d, c)) => (v, d, c, manifest_source),
954 None => (
955 installed.get(pkg).cloned().flatten().unwrap_or_default(),
956 String::new(),
957 String::new(),
958 manifest_source,
959 ),
960 }
961 } else {
962 return Err(format!(
963 "Package '{pkg}' not found in remote indices or locally installed packages"
964 ));
965 }
966 };
967
968 let card_rows = card::list(Some(pkg)).unwrap_or_default();
970 let cards_json = card::summaries_to_json(&card_rows);
971
972 let aliases_json = match card::alias_list(Some(pkg)) {
974 Ok(rows) => card::aliases_to_json(&rows),
975 Err(_) => serde_json::json!([]),
976 };
977
978 let card_count = card_rows.len();
980 let best_pass_rate = card_rows
981 .iter()
982 .filter_map(|c| c.pass_rate)
983 .fold(f64::NEG_INFINITY, f64::max);
984 let best_pass_rate = if best_pass_rate.is_finite() {
985 Some(best_pass_rate)
986 } else {
987 None
988 };
989
990 let eval_count = count_evals_for_pkg(pkg);
992
993 let response = serde_json::json!({
994 "pkg": {
995 "name": pkg,
996 "version": version,
997 "description": description,
998 "category": category,
999 "source": source,
1000 "installed": is_installed,
1001 },
1002 "cards": cards_json,
1003 "aliases": aliases_json,
1004 "stats": {
1005 "card_count": card_count,
1006 "eval_count": eval_count,
1007 "best_pass_rate": best_pass_rate,
1008 },
1009 });
1010 Ok(response.to_string())
1011 }
1012
1013 pub fn hub_search(
1018 &self,
1019 query: Option<&str>,
1020 category: Option<&str>,
1021 installed_only: Option<bool>,
1022 limit: Option<usize>,
1023 ) -> Result<String, String> {
1024 let (remote, warnings) = fetch_remote_indices();
1025 let mut results = merge(&remote);
1026
1027 if let Some(q) = query {
1029 if !q.is_empty() {
1030 results.retain(|r| matches_query(r, q));
1031 }
1032 }
1033
1034 if let Some(cat) = category {
1036 let cat_lower = cat.to_lowercase();
1037 results.retain(|r| r.category.to_lowercase() == cat_lower);
1038 }
1039
1040 if let Some(true) = installed_only {
1042 results.retain(|r| r.installed);
1043 }
1044
1045 results.sort_by(|a, b| {
1047 b.installed
1048 .cmp(&a.installed)
1049 .then_with(|| a.name.cmp(&b.name))
1050 });
1051
1052 let total = results.len();
1054 let limit = limit.unwrap_or(50);
1055 results.truncate(limit);
1056
1057 let sources = discover_index_urls();
1059
1060 let mut json = serde_json::json!({
1061 "results": results,
1062 "total": total,
1063 "sources": sources,
1064 });
1065 if !warnings.is_empty() {
1066 json["warnings"] = serde_json::json!(warnings);
1067 }
1068 Ok(json.to_string())
1069 }
1070}
1071
1072#[cfg(test)]
1073mod tests {
1074 use super::*;
1075
1076 #[test]
1077 fn repo_to_index_url_github() {
1078 assert_eq!(
1079 repo_to_index_url("https://github.com/ynishi/algocline-bundled-packages"),
1080 Some(
1081 "https://raw.githubusercontent.com/ynishi/algocline-bundled-packages/main/hub_index.json"
1082 .to_string()
1083 )
1084 );
1085 }
1086
1087 #[test]
1088 fn repo_to_index_url_github_trailing_slash() {
1089 assert_eq!(
1090 repo_to_index_url("https://github.com/user/repo/"),
1091 Some("https://raw.githubusercontent.com/user/repo/main/hub_index.json".to_string())
1092 );
1093 }
1094
1095 #[test]
1096 fn repo_to_index_url_github_dot_git() {
1097 assert_eq!(
1098 repo_to_index_url("https://github.com/user/repo.git"),
1099 Some("https://raw.githubusercontent.com/user/repo/main/hub_index.json".to_string())
1100 );
1101 }
1102
1103 #[test]
1104 fn repo_to_index_url_direct_json() {
1105 assert_eq!(
1106 repo_to_index_url("https://example.com/my_index.json"),
1107 Some("https://example.com/my_index.json".to_string())
1108 );
1109 }
1110
1111 #[test]
1112 fn repo_to_index_url_unknown_host_no_json() {
1113 assert_eq!(repo_to_index_url("https://example.com/some-repo"), None);
1114 }
1115
1116 #[test]
1117 fn repo_to_index_url_local_path() {
1118 assert_eq!(repo_to_index_url("/home/user/my-pkg"), None);
1119 }
1120
1121 #[test]
1122 fn cache_key_stable() {
1123 let k1 = cache_key("https://example.com/index.json");
1124 let k2 = cache_key("https://example.com/index.json");
1125 assert_eq!(k1, k2);
1126 assert_eq!(k1.len(), 16); }
1128
1129 #[test]
1130 fn cache_key_different_urls() {
1131 let k1 = cache_key("https://a.com/index.json");
1132 let k2 = cache_key("https://b.com/index.json");
1133 assert_ne!(k1, k2);
1134 }
1135
1136 #[test]
1137 fn parse_meta_flat() {
1138 let tmp = tempfile::tempdir().unwrap();
1139 let path = tmp.path().join("init.lua");
1140 std::fs::write(
1141 &path,
1142 r#"
1143local M = {}
1144M.meta = {
1145 name = "my_pkg",
1146 version = "1.0.0",
1147 description = "A test package",
1148 category = "reasoning",
1149}
1150return M
1151"#,
1152 )
1153 .unwrap();
1154
1155 let result = parse_meta_from_init_lua(&path).unwrap();
1156 assert_eq!(result.0, "my_pkg");
1157 assert_eq!(result.1, "1.0.0");
1158 assert_eq!(result.2, "A test package");
1159 assert_eq!(result.3, "reasoning");
1160 }
1161
1162 #[test]
1163 fn parse_meta_nested_table() {
1164 let tmp = tempfile::tempdir().unwrap();
1165 let path = tmp.path().join("init.lua");
1166 std::fs::write(
1167 &path,
1168 r#"
1169local M = {}
1170M.meta = {
1171 name = "nested_pkg",
1172 tags = { "a", "b" },
1173 description = "After nested",
1174}
1175return M
1176"#,
1177 )
1178 .unwrap();
1179
1180 let result = parse_meta_from_init_lua(&path).unwrap();
1181 assert_eq!(result.0, "nested_pkg");
1182 assert_eq!(result.2, "After nested");
1183 }
1184
1185 #[test]
1186 fn parse_meta_word_boundary() {
1187 let tmp = tempfile::tempdir().unwrap();
1188 let path = tmp.path().join("init.lua");
1189 std::fs::write(
1190 &path,
1191 r#"
1192local M = {}
1193M.meta = {
1194 name = "wb_pkg",
1195 short_description = "should not match",
1196 description = "correct one",
1197}
1198return M
1199"#,
1200 )
1201 .unwrap();
1202
1203 let result = parse_meta_from_init_lua(&path).unwrap();
1204 assert_eq!(result.0, "wb_pkg");
1205 assert_eq!(result.2, "correct one");
1206 }
1207
1208 #[test]
1209 fn merge_dedup_uses_hashset() {
1210 let remote = HubIndex {
1213 schema_version: "hub_index/v0".into(),
1214 updated_at: String::new(),
1215 packages: vec![IndexEntry {
1216 name: "remote_only".into(),
1217 version: "1.0".into(),
1218 description: "from remote".into(),
1219 category: "test".into(),
1220 source: String::new(),
1221 card_count: 0,
1222 best_card: None,
1223 }],
1224 };
1225
1226 let results = merge(&remote);
1227 assert!(results.iter().any(|r| r.name == "remote_only"));
1229 }
1230}