1use std::collections::{HashMap, HashSet};
17use std::path::PathBuf;
18
19use serde::{Deserialize, Serialize};
20
21use super::manifest;
22use super::resolve::AUTO_INSTALL_SOURCES;
23use super::AppService;
24
25const CACHE_TTL_SECS: u64 = 3600;
29
30const HTTP_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(30);
32
33#[derive(Debug, Clone, Serialize, Deserialize)]
37pub(crate) struct HubIndex {
38 pub schema_version: String,
39 #[serde(default)]
40 pub updated_at: String,
41 #[serde(default)]
42 pub packages: Vec<IndexEntry>,
43}
44
45#[derive(Debug, Clone, Serialize, Deserialize)]
47pub(crate) struct IndexEntry {
48 pub name: String,
49 #[serde(default)]
50 pub version: String,
51 #[serde(default)]
52 pub description: String,
53 #[serde(default)]
54 pub category: String,
55 #[serde(default)]
56 pub source: String,
57 #[serde(default)]
58 pub card_count: usize,
59 #[serde(default)]
60 pub best_card: Option<BestCard>,
61}
62
63#[derive(Debug, Clone, Serialize, Deserialize)]
65pub(crate) struct BestCard {
66 pub card_id: String,
67 #[serde(default)]
68 pub model: String,
69 #[serde(default)]
70 pub pass_rate: f64,
71 #[serde(default)]
72 pub scenario: String,
73}
74
75#[derive(Debug, Clone, Serialize)]
77struct SearchResult {
78 name: String,
79 version: String,
80 description: String,
81 category: String,
82 source: String,
83 installed: bool,
84 card_count: usize,
85 best_card: Option<BestCard>,
86}
87
88#[derive(Debug, Clone, Serialize, Deserialize)]
97pub(crate) struct RegistryEntry {
98 pub source: String,
100 pub origin: String,
102 pub added_at: String,
104}
105
106#[derive(Debug, Clone, Serialize, Deserialize, Default)]
108pub(crate) struct HubRegistries {
109 pub registries: Vec<RegistryEntry>,
110}
111
112fn registries_path() -> Result<PathBuf, String> {
113 let home = dirs::home_dir().ok_or("Cannot determine home directory")?;
114 Ok(home.join(".algocline").join("hub_registries.json"))
115}
116
117fn load_registries() -> HubRegistries {
119 let path = match registries_path() {
120 Ok(p) => p,
121 Err(_) => return HubRegistries::default(),
122 };
123 if !path.exists() {
124 return HubRegistries::default();
125 }
126 std::fs::read_to_string(&path)
127 .ok()
128 .and_then(|c| serde_json::from_str(&c).ok())
129 .unwrap_or_default()
130}
131
132pub(crate) fn register_source(source: &str, origin: &str) {
139 let normalized = source.trim_end_matches('/').to_string();
140 if normalized.is_empty() {
141 return;
142 }
143 if normalized.starts_with('/') || normalized.starts_with('.') {
145 return;
146 }
147
148 let path = match registries_path() {
149 Ok(p) => p,
150 Err(_) => return,
151 };
152 if let Some(parent) = path.parent() {
153 let _ = std::fs::create_dir_all(parent);
154 }
155
156 let mut reg = load_registries();
158
159 if reg
161 .registries
162 .iter()
163 .any(|e| e.source.trim_end_matches('/') == normalized)
164 {
165 return;
166 }
167
168 reg.registries.push(RegistryEntry {
169 source: normalized,
170 origin: origin.to_string(),
171 added_at: manifest::now_iso8601(),
172 });
173
174 match serde_json::to_string_pretty(®) {
176 Ok(json) => {
177 let tmp_path = path.with_extension("json.tmp");
178 if let Err(e) = std::fs::write(&tmp_path, &json) {
179 tracing::warn!("failed to write hub registries tmp: {e}");
180 return;
181 }
182 if let Err(e) = std::fs::rename(&tmp_path, &path) {
183 tracing::warn!("failed to rename hub registries: {e}");
184 let _ = std::fs::remove_file(&tmp_path);
186 }
187 }
188 Err(e) => tracing::warn!("failed to serialize hub registries: {e}"),
189 }
190}
191
192fn repo_to_index_url(repo_url: &str) -> Option<String> {
206 let trimmed = repo_url.trim_end_matches('/').trim_end_matches(".git");
207 if let Some(path) = trimmed.strip_prefix("https://github.com/") {
208 let parts: Vec<&str> = path.splitn(3, '/').collect();
210 if parts.len() >= 2 {
211 return Some(format!(
212 "https://raw.githubusercontent.com/{}/{}/main/hub_index.json",
213 parts[0], parts[1]
214 ));
215 }
216 }
217 if trimmed.ends_with(".json") {
219 Some(trimmed.to_string())
220 } else {
221 None
222 }
223}
224
225fn discover_index_urls() -> Vec<String> {
227 let mut repo_urls: HashSet<String> = HashSet::new();
228
229 let reg = load_registries();
231 for entry in ®.registries {
232 let normalized = entry.source.trim_end_matches('/').to_string();
233 if !normalized.is_empty() {
234 repo_urls.insert(normalized);
235 }
236 }
237
238 if let Ok(m) = manifest::load_manifest() {
240 for entry in m.packages.values() {
241 let normalized = entry.source.trim_end_matches('/').to_string();
242 if !normalized.is_empty() && !normalized.starts_with('/') {
243 repo_urls.insert(normalized);
244 }
245 }
246 }
247
248 for url in AUTO_INSTALL_SOURCES {
250 repo_urls.insert(url.to_string());
251 }
252
253 let mut index_urls: Vec<String> = repo_urls
255 .iter()
256 .filter_map(|url| repo_to_index_url(url))
257 .collect();
258 index_urls.sort();
259 index_urls.dedup();
260 index_urls
261}
262
263fn cache_dir() -> Result<PathBuf, String> {
271 let home = dirs::home_dir().ok_or("Cannot determine home directory")?;
272 Ok(home.join(".algocline").join("hub_cache"))
273}
274
275fn cache_key(url: &str) -> String {
276 let mut h: u64 = 0xcbf2_9ce4_8422_2325; for b in url.as_bytes() {
280 h ^= *b as u64;
281 h = h.wrapping_mul(0x0100_0000_01b3); }
283 format!("{h:016x}")
284}
285
286fn load_cached(url: &str) -> Option<HubIndex> {
288 let dir = cache_dir().ok()?;
289 let path = dir.join(format!("{}.json", cache_key(url)));
290 if !path.exists() {
291 return None;
292 }
293 let metadata = std::fs::metadata(&path).ok()?;
294 let age = metadata.modified().ok()?.elapsed().ok()?;
295 if age.as_secs() > CACHE_TTL_SECS {
296 return None;
297 }
298 let content = std::fs::read_to_string(&path).ok()?;
299 serde_json::from_str(&content).ok()
300}
301
302fn save_cached(url: &str, index: &HubIndex) {
304 let dir = match cache_dir() {
305 Ok(d) => d,
306 Err(e) => {
307 tracing::warn!("hub cache dir unavailable: {e}");
308 return;
309 }
310 };
311 if let Err(e) = std::fs::create_dir_all(&dir) {
312 tracing::warn!("failed to create hub cache dir: {e}");
313 return;
314 }
315 let path = dir.join(format!("{}.json", cache_key(url)));
316 match serde_json::to_string_pretty(index) {
317 Ok(json) => {
318 if let Err(e) = std::fs::write(&path, json) {
319 tracing::warn!("failed to write hub cache {}: {e}", path.display());
320 }
321 }
322 Err(e) => tracing::warn!("failed to serialize hub cache: {e}"),
323 }
324}
325
326fn fetch_one(url: &str) -> Result<HubIndex, String> {
330 if let Some(cached) = load_cached(url) {
331 return Ok(cached);
332 }
333
334 let agent = ureq::Agent::new_with_config(
335 ureq::config::Config::builder()
336 .timeout_global(Some(HTTP_TIMEOUT))
337 .build(),
338 );
339 let body: String = agent
340 .get(url)
341 .call()
342 .map_err(|e| format!("Failed to fetch {url}: {e}"))?
343 .body_mut()
344 .read_to_string()
345 .map_err(|e| format!("Failed to read response from {url}: {e}"))?;
346
347 let index: HubIndex = serde_json::from_str(&body)
348 .map_err(|e| format!("Failed to parse index from {url}: {e}"))?;
349
350 save_cached(url, &index);
351 Ok(index)
352}
353
354fn fetch_remote_indices() -> (HubIndex, Vec<String>) {
357 let urls = discover_index_urls();
358 let mut all_packages: Vec<IndexEntry> = Vec::new();
359 let mut seen_names: HashSet<String> = HashSet::new();
360 let mut warnings: Vec<String> = Vec::new();
361
362 for url in &urls {
363 match fetch_one(url) {
364 Ok(index) => {
365 for entry in index.packages {
366 if seen_names.insert(entry.name.clone()) {
367 all_packages.push(entry);
368 }
369 }
371 }
372 Err(e) => {
373 warnings.push(e);
374 }
375 }
376 }
377
378 if all_packages.is_empty() && !warnings.is_empty() {
379 warnings.insert(
380 0,
381 "all remote indices unavailable, showing local packages only".to_string(),
382 );
383 }
384
385 let merged = HubIndex {
386 schema_version: "hub_index/v0".into(),
387 updated_at: String::new(),
388 packages: all_packages,
389 };
390 (merged, warnings)
391}
392
393fn installed_packages() -> HashMap<String, Option<String>> {
398 let mut map = HashMap::new();
399
400 if let Ok(m) = manifest::load_manifest() {
402 for (name, entry) in &m.packages {
403 map.insert(name.clone(), entry.version.clone());
404 }
405 }
406
407 if let Some(home) = dirs::home_dir() {
409 let pkg_dir = home.join(".algocline").join("packages");
410 if let Ok(entries) = std::fs::read_dir(&pkg_dir) {
411 for entry in entries.flatten() {
412 if entry.path().is_dir() {
413 if let Some(name) = entry.file_name().to_str() {
414 map.entry(name.to_string()).or_insert(None);
415 }
416 }
417 }
418 }
419 }
420
421 map
422}
423
424fn local_card_counts() -> HashMap<String, usize> {
426 let mut map = HashMap::new();
427 let home = match dirs::home_dir() {
428 Some(h) => h,
429 None => return map,
430 };
431 let cards_dir = home.join(".algocline").join("cards");
432 let entries = match std::fs::read_dir(&cards_dir) {
433 Ok(e) => e,
434 Err(_) => return map,
435 };
436 for entry in entries.flatten() {
437 if !entry.path().is_dir() {
438 continue;
439 }
440 let pkg = match entry.file_name().to_str() {
441 Some(n) => n.to_string(),
442 None => continue,
443 };
444 let count = std::fs::read_dir(entry.path())
445 .map(|es| {
446 es.flatten()
447 .filter(|e| e.path().extension().is_some_and(|ext| ext == "toml"))
448 .count()
449 })
450 .unwrap_or(0);
451 if count > 0 {
452 map.insert(pkg, count);
453 }
454 }
455 map
456}
457
458fn merge(remote: &HubIndex) -> Vec<SearchResult> {
462 let installed = installed_packages();
463 let card_counts = local_card_counts();
464
465 let mut seen: HashSet<String> = HashSet::new();
466 let mut results: Vec<SearchResult> = Vec::new();
467
468 for entry in &remote.packages {
469 let is_installed = installed.contains_key(&entry.name);
470 let local_cards = card_counts.get(&entry.name).copied().unwrap_or(0);
471
472 seen.insert(entry.name.clone());
473 results.push(SearchResult {
474 name: entry.name.clone(),
475 version: entry.version.clone(),
476 description: entry.description.clone(),
477 category: entry.category.clone(),
478 source: entry.source.clone(),
479 installed: is_installed,
480 card_count: if is_installed && local_cards > entry.card_count {
481 local_cards
482 } else {
483 entry.card_count
484 },
485 best_card: entry.best_card.clone(),
486 });
487 }
488
489 for (name, version) in &installed {
491 if seen.contains(name) {
492 continue;
493 }
494 results.push(SearchResult {
495 name: name.clone(),
496 version: version.clone().unwrap_or_default(),
497 description: String::new(),
498 category: String::new(),
499 source: String::new(),
500 installed: true,
501 card_count: card_counts.get(name).copied().unwrap_or(0),
502 best_card: None,
503 });
504 }
505
506 results
507}
508
509fn matches_query(result: &SearchResult, query: &str) -> bool {
512 let q = query.to_lowercase();
513 result.name.to_lowercase().contains(&q)
514 || result.description.to_lowercase().contains(&q)
515 || result.category.to_lowercase().contains(&q)
516}
517
518fn parse_meta_from_init_lua(path: &std::path::Path) -> Option<(String, String, String, String)> {
530 let content = std::fs::read_to_string(path).ok()?;
531 let mut limit = 2048.min(content.len());
533 while limit > 0 && !content.is_char_boundary(limit) {
534 limit -= 1;
535 }
536 let head = &content[..limit];
537
538 let meta_start = head.find("M.meta")?;
540 let brace_start = head[meta_start..].find('{')? + meta_start;
541
542 let mut depth = 0;
544 let mut brace_end = None;
545 for (i, ch) in head[brace_start..].char_indices() {
546 match ch {
547 '{' => depth += 1,
548 '}' => {
549 depth -= 1;
550 if depth == 0 {
551 brace_end = Some(brace_start + i);
552 break;
553 }
554 }
555 _ => {}
556 }
557 }
558 let brace_end = brace_end?;
559 let block = &head[brace_start + 1..brace_end];
560
561 let extract = |field: &str| -> String {
562 let mut search_from = 0;
568 while let Some(rel) = block[search_from..].find(field) {
569 let pos = search_from + rel;
570 let word_boundary = if pos == 0 {
572 true
573 } else {
574 let prev = block.as_bytes()[pos - 1];
575 !(prev.is_ascii_alphanumeric() || prev == b'_')
576 };
577 if word_boundary {
578 let after = &block[pos + field.len()..];
579 if let Some(q_start_rel) = after.find('"') {
580 let q_start = q_start_rel + 1;
581 if let Some(q_end_rel) = after[q_start..].find('"') {
582 return after[q_start..q_start + q_end_rel].to_string();
583 }
584 }
585 }
586 search_from = pos + field.len();
587 }
588 String::new()
589 };
590
591 let name = extract("name");
592 if name.is_empty() {
593 return None;
594 }
595 Some((
596 name,
597 extract("version"),
598 extract("description"),
599 extract("category"),
600 ))
601}
602
603fn build_index(source_dir: Option<&std::path::Path>) -> HubIndex {
612 let empty = || HubIndex {
613 schema_version: "hub_index/v0".into(),
614 updated_at: super::manifest::now_iso8601(),
615 packages: Vec::new(),
616 };
617
618 let pkg_dir = match source_dir {
619 Some(d) => d.to_path_buf(),
620 None => {
621 let home = match dirs::home_dir() {
622 Some(h) => h,
623 None => return empty(),
624 };
625 home.join(".algocline").join("packages")
626 }
627 };
628
629 let use_local_state = source_dir.is_none();
630 let card_counts = if use_local_state {
631 local_card_counts()
632 } else {
633 HashMap::new()
634 };
635 let manifest = if use_local_state {
636 manifest::load_manifest().unwrap_or_default()
637 } else {
638 manifest::Manifest::default()
639 };
640
641 let mut entries = Vec::new();
642
643 let dir_entries = match std::fs::read_dir(&pkg_dir) {
644 Ok(e) => e,
645 Err(_) => return empty(),
646 };
647
648 for entry in dir_entries.flatten() {
649 if !entry.path().is_dir() {
650 continue;
651 }
652 let dir_name = match entry.file_name().to_str() {
653 Some(n) if !n.starts_with('.') && !n.starts_with('_') => n.to_string(),
654 _ => continue,
655 };
656
657 let init_lua = entry.path().join("init.lua");
658 if !init_lua.exists() {
659 continue;
660 }
661
662 let (name, version, description, category) = parse_meta_from_init_lua(&init_lua)
663 .unwrap_or_else(|| {
664 (
665 dir_name.clone(),
666 String::new(),
667 String::new(),
668 String::new(),
669 )
670 });
671
672 let source = manifest
674 .packages
675 .get(&dir_name)
676 .map(|e| e.source.clone())
677 .unwrap_or_default();
678
679 entries.push(IndexEntry {
680 name,
681 version,
682 description,
683 category,
684 source,
685 card_count: card_counts.get(&dir_name).copied().unwrap_or(0),
686 best_card: None,
687 });
688 }
689
690 entries.sort_by(|a, b| a.name.cmp(&b.name));
691
692 HubIndex {
693 schema_version: "hub_index/v0".into(),
694 updated_at: super::manifest::now_iso8601(),
695 packages: entries,
696 }
697}
698
699impl AppService {
702 pub fn hub_reindex(
711 &self,
712 output_path: Option<&str>,
713 source_dir: Option<&str>,
714 ) -> Result<String, String> {
715 let src = source_dir.map(std::path::Path::new);
716 if let Some(d) = src {
717 if !d.is_dir() {
718 return Err(format!("source_dir '{}' is not a directory", d.display()));
719 }
720 }
721 let index = build_index(src);
722
723 let written_path = if let Some(path) = output_path {
724 let json = serde_json::to_string_pretty(&index)
725 .map_err(|e| format!("Failed to serialize index: {e}"))?;
726 std::fs::write(path, &json)
727 .map_err(|e| format!("Failed to write index to {path}: {e}"))?;
728 Some(path.to_string())
729 } else {
730 None
731 };
732
733 let response = serde_json::json!({
734 "package_count": index.packages.len(),
735 "updated_at": index.updated_at,
736 "output_path": written_path,
737 "source_dir": source_dir,
738 });
739 Ok(response.to_string())
740 }
741
742 pub fn hub_search(
747 &self,
748 query: Option<&str>,
749 category: Option<&str>,
750 installed_only: Option<bool>,
751 limit: Option<usize>,
752 ) -> Result<String, String> {
753 let (remote, warnings) = fetch_remote_indices();
754 let mut results = merge(&remote);
755
756 if let Some(q) = query {
758 if !q.is_empty() {
759 results.retain(|r| matches_query(r, q));
760 }
761 }
762
763 if let Some(cat) = category {
765 let cat_lower = cat.to_lowercase();
766 results.retain(|r| r.category.to_lowercase() == cat_lower);
767 }
768
769 if let Some(true) = installed_only {
771 results.retain(|r| r.installed);
772 }
773
774 results.sort_by(|a, b| {
776 b.installed
777 .cmp(&a.installed)
778 .then_with(|| a.name.cmp(&b.name))
779 });
780
781 let total = results.len();
783 let limit = limit.unwrap_or(50);
784 results.truncate(limit);
785
786 let sources = discover_index_urls();
788
789 let mut json = serde_json::json!({
790 "results": results,
791 "total": total,
792 "sources": sources,
793 });
794 if !warnings.is_empty() {
795 json["warnings"] = serde_json::json!(warnings);
796 }
797 Ok(json.to_string())
798 }
799}
800
801#[cfg(test)]
802mod tests {
803 use super::*;
804
805 #[test]
806 fn repo_to_index_url_github() {
807 assert_eq!(
808 repo_to_index_url("https://github.com/ynishi/algocline-bundled-packages"),
809 Some(
810 "https://raw.githubusercontent.com/ynishi/algocline-bundled-packages/main/hub_index.json"
811 .to_string()
812 )
813 );
814 }
815
816 #[test]
817 fn repo_to_index_url_github_trailing_slash() {
818 assert_eq!(
819 repo_to_index_url("https://github.com/user/repo/"),
820 Some("https://raw.githubusercontent.com/user/repo/main/hub_index.json".to_string())
821 );
822 }
823
824 #[test]
825 fn repo_to_index_url_github_dot_git() {
826 assert_eq!(
827 repo_to_index_url("https://github.com/user/repo.git"),
828 Some("https://raw.githubusercontent.com/user/repo/main/hub_index.json".to_string())
829 );
830 }
831
832 #[test]
833 fn repo_to_index_url_direct_json() {
834 assert_eq!(
835 repo_to_index_url("https://example.com/my_index.json"),
836 Some("https://example.com/my_index.json".to_string())
837 );
838 }
839
840 #[test]
841 fn repo_to_index_url_unknown_host_no_json() {
842 assert_eq!(repo_to_index_url("https://example.com/some-repo"), None);
843 }
844
845 #[test]
846 fn repo_to_index_url_local_path() {
847 assert_eq!(repo_to_index_url("/home/user/my-pkg"), None);
848 }
849
850 #[test]
851 fn cache_key_stable() {
852 let k1 = cache_key("https://example.com/index.json");
853 let k2 = cache_key("https://example.com/index.json");
854 assert_eq!(k1, k2);
855 assert_eq!(k1.len(), 16); }
857
858 #[test]
859 fn cache_key_different_urls() {
860 let k1 = cache_key("https://a.com/index.json");
861 let k2 = cache_key("https://b.com/index.json");
862 assert_ne!(k1, k2);
863 }
864
865 #[test]
866 fn parse_meta_flat() {
867 let tmp = tempfile::tempdir().unwrap();
868 let path = tmp.path().join("init.lua");
869 std::fs::write(
870 &path,
871 r#"
872local M = {}
873M.meta = {
874 name = "my_pkg",
875 version = "1.0.0",
876 description = "A test package",
877 category = "reasoning",
878}
879return M
880"#,
881 )
882 .unwrap();
883
884 let result = parse_meta_from_init_lua(&path).unwrap();
885 assert_eq!(result.0, "my_pkg");
886 assert_eq!(result.1, "1.0.0");
887 assert_eq!(result.2, "A test package");
888 assert_eq!(result.3, "reasoning");
889 }
890
891 #[test]
892 fn parse_meta_nested_table() {
893 let tmp = tempfile::tempdir().unwrap();
894 let path = tmp.path().join("init.lua");
895 std::fs::write(
896 &path,
897 r#"
898local M = {}
899M.meta = {
900 name = "nested_pkg",
901 tags = { "a", "b" },
902 description = "After nested",
903}
904return M
905"#,
906 )
907 .unwrap();
908
909 let result = parse_meta_from_init_lua(&path).unwrap();
910 assert_eq!(result.0, "nested_pkg");
911 assert_eq!(result.2, "After nested");
912 }
913
914 #[test]
915 fn parse_meta_word_boundary() {
916 let tmp = tempfile::tempdir().unwrap();
917 let path = tmp.path().join("init.lua");
918 std::fs::write(
919 &path,
920 r#"
921local M = {}
922M.meta = {
923 name = "wb_pkg",
924 short_description = "should not match",
925 description = "correct one",
926}
927return M
928"#,
929 )
930 .unwrap();
931
932 let result = parse_meta_from_init_lua(&path).unwrap();
933 assert_eq!(result.0, "wb_pkg");
934 assert_eq!(result.2, "correct one");
935 }
936
937 #[test]
938 fn merge_dedup_uses_hashset() {
939 let remote = HubIndex {
942 schema_version: "hub_index/v0".into(),
943 updated_at: String::new(),
944 packages: vec![IndexEntry {
945 name: "remote_only".into(),
946 version: "1.0".into(),
947 description: "from remote".into(),
948 category: "test".into(),
949 source: String::new(),
950 card_count: 0,
951 best_card: None,
952 }],
953 };
954
955 let results = merge(&remote);
956 assert!(results.iter().any(|r| r.name == "remote_only"));
958 }
959}