1use std::collections::{HashMap, HashSet};
77use std::path::PathBuf;
78
79use serde::{Deserialize, Serialize};
80
81use super::list_opts::{
82 apply_sort_by_value, matches_filter, parse_sort, project_fields, resolve_fields, ListOpts,
83 HUB_SEARCH_FULL, HUB_SEARCH_SUMMARY,
84};
85use super::manifest;
86use super::resolve::AUTO_INSTALL_SOURCES;
87use super::AppService;
88
89const CACHE_TTL_SECS: u64 = 3600;
93
94const HTTP_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(30);
96
97#[derive(Debug, Clone, Serialize, Deserialize)]
101pub(crate) struct HubIndex {
102 pub schema_version: String,
103 #[serde(default)]
104 pub updated_at: String,
105 #[serde(default)]
106 pub packages: Vec<IndexEntry>,
107}
108
109#[derive(Debug, Clone, Serialize, Deserialize)]
111pub(crate) struct IndexEntry {
112 pub name: String,
113 #[serde(default)]
114 pub version: String,
115 #[serde(default)]
116 pub description: String,
117 #[serde(default)]
118 pub category: String,
119 #[serde(default)]
120 pub source: String,
121 #[serde(default)]
122 pub card_count: usize,
123 #[serde(default)]
124 pub best_card: Option<BestCard>,
125 #[serde(default)]
127 pub docstring: String,
128}
129
130#[derive(Debug, Clone, Serialize, Deserialize)]
132pub(crate) struct BestCard {
133 pub card_id: String,
134 #[serde(default)]
135 pub model: String,
136 #[serde(default)]
137 pub pass_rate: f64,
138 #[serde(default)]
139 pub scenario: String,
140}
141
142#[derive(Debug, Clone, Serialize)]
155struct SearchResult {
156 name: String,
157 version: String,
158 description: String,
159 category: String,
160 source: String,
161 installed: bool,
162 card_count: usize,
163 best_card: Option<BestCard>,
164 #[serde(skip_serializing)]
165 docstring: String,
166 #[serde(skip_serializing_if = "Option::is_none")]
167 docstring_matched: Option<bool>,
168}
169
170impl SearchResult {
171 fn to_value_with_optional_docstring(&self, include_docstring: bool) -> serde_json::Value {
184 let mut v = serde_json::to_value(self).unwrap_or(serde_json::Value::Null);
185 if include_docstring {
186 if let serde_json::Value::Object(ref mut map) = v {
187 map.insert(
188 "docstring".to_string(),
189 serde_json::Value::String(self.docstring.clone()),
190 );
191 }
192 }
193 v
194 }
195}
196
197#[derive(Debug, Clone, Serialize, Deserialize)]
206pub(crate) struct RegistryEntry {
207 pub source: String,
209 pub origin: String,
211 pub added_at: String,
213}
214
215#[derive(Debug, Clone, Serialize, Deserialize, Default)]
217pub(crate) struct HubRegistries {
218 pub registries: Vec<RegistryEntry>,
219}
220
221fn registries_path() -> Result<PathBuf, String> {
222 let home = dirs::home_dir().ok_or("Cannot determine home directory")?;
223 Ok(home.join(".algocline").join("hub_registries.json"))
224}
225
226fn load_registries() -> HubRegistries {
228 let path = match registries_path() {
229 Ok(p) => p,
230 Err(_) => return HubRegistries::default(),
231 };
232 if !path.exists() {
233 return HubRegistries::default();
234 }
235 std::fs::read_to_string(&path)
236 .ok()
237 .and_then(|c| serde_json::from_str(&c).ok())
238 .unwrap_or_default()
239}
240
241pub(crate) fn register_source(source: &str, origin: &str) {
248 let normalized = source.trim_end_matches('/').to_string();
249 if normalized.is_empty() {
250 return;
251 }
252 if normalized.starts_with('/') || normalized.starts_with('.') {
254 return;
255 }
256
257 let path = match registries_path() {
258 Ok(p) => p,
259 Err(_) => return,
260 };
261 if let Some(parent) = path.parent() {
262 let _ = std::fs::create_dir_all(parent);
263 }
264
265 let mut reg = load_registries();
267
268 if reg
270 .registries
271 .iter()
272 .any(|e| e.source.trim_end_matches('/') == normalized)
273 {
274 return;
275 }
276
277 reg.registries.push(RegistryEntry {
278 source: normalized,
279 origin: origin.to_string(),
280 added_at: manifest::now_iso8601(),
281 });
282
283 match serde_json::to_string_pretty(®) {
285 Ok(json) => {
286 let tmp_path = path.with_extension("json.tmp");
287 if let Err(e) = std::fs::write(&tmp_path, &json) {
288 tracing::warn!("failed to write hub registries tmp: {e}");
289 return;
290 }
291 if let Err(e) = std::fs::rename(&tmp_path, &path) {
292 tracing::warn!("failed to rename hub registries: {e}");
293 let _ = std::fs::remove_file(&tmp_path);
295 }
296 }
297 Err(e) => tracing::warn!("failed to serialize hub registries: {e}"),
298 }
299}
300
301fn collection_url_from_config() -> Option<String> {
313 let home = dirs::home_dir()?;
314 let path = home.join(".algocline").join("config.toml");
315 let content = std::fs::read_to_string(&path).ok()?;
316 let doc: toml_edit::DocumentMut = content.parse().ok()?;
317 let url = doc
318 .get("hub")?
319 .get("collection_url")?
320 .as_str()?
321 .trim()
322 .to_string();
323 if url.is_empty() {
324 None
325 } else {
326 Some(url)
327 }
328}
329
330fn repo_to_index_url(repo_url: &str) -> Option<String> {
345 let trimmed = repo_url.trim_end_matches('/').trim_end_matches(".git");
346 if let Some(path) = trimmed.strip_prefix("https://github.com/") {
347 let parts: Vec<&str> = path.splitn(3, '/').collect();
349 if parts.len() >= 2 {
350 return Some(format!(
351 "https://raw.githubusercontent.com/{}/{}/main/hub_index.json",
352 parts[0], parts[1]
353 ));
354 }
355 }
356 if trimmed.ends_with(".json") {
358 Some(trimmed.to_string())
359 } else {
360 None
361 }
362}
363
364fn discover_index_urls() -> Vec<String> {
366 let mut index_urls: Vec<String> = Vec::new();
367
368 if let Some(url) = collection_url_from_config() {
370 index_urls.push(url);
371 }
372
373 let mut repo_urls: HashSet<String> = HashSet::new();
374
375 let reg = load_registries();
377 for entry in ®.registries {
378 let normalized = entry.source.trim_end_matches('/').to_string();
379 if !normalized.is_empty() {
380 repo_urls.insert(normalized);
381 }
382 }
383
384 if let Ok(m) = manifest::load_manifest() {
386 for entry in m.packages.values() {
387 let normalized = entry.source.trim_end_matches('/').to_string();
388 if !normalized.is_empty() && !normalized.starts_with('/') {
389 repo_urls.insert(normalized);
390 }
391 }
392 }
393
394 for url in AUTO_INSTALL_SOURCES {
396 repo_urls.insert(url.to_string());
397 }
398
399 let existing: HashSet<String> = index_urls.iter().cloned().collect();
401 let mut derived: Vec<String> = repo_urls
402 .iter()
403 .filter_map(|url| repo_to_index_url(url))
404 .filter(|url| !existing.contains(url))
405 .collect();
406 derived.sort();
407 derived.dedup();
408 index_urls.extend(derived);
409
410 index_urls
411}
412
413fn cache_dir() -> Result<PathBuf, String> {
421 let home = dirs::home_dir().ok_or("Cannot determine home directory")?;
422 Ok(home.join(".algocline").join("hub_cache"))
423}
424
425fn cache_key(url: &str) -> String {
426 let mut h: u64 = 0xcbf2_9ce4_8422_2325; for b in url.as_bytes() {
430 h ^= *b as u64;
431 h = h.wrapping_mul(0x0100_0000_01b3); }
433 format!("{h:016x}")
434}
435
436fn load_cached(url: &str) -> Option<HubIndex> {
438 let dir = cache_dir().ok()?;
439 let path = dir.join(format!("{}.json", cache_key(url)));
440 if !path.exists() {
441 return None;
442 }
443 let metadata = std::fs::metadata(&path).ok()?;
444 let age = metadata.modified().ok()?.elapsed().ok()?;
445 if age.as_secs() > CACHE_TTL_SECS {
446 return None;
447 }
448 let content = std::fs::read_to_string(&path).ok()?;
449 serde_json::from_str(&content).ok()
450}
451
452fn save_cached(url: &str, index: &HubIndex) {
454 let dir = match cache_dir() {
455 Ok(d) => d,
456 Err(e) => {
457 tracing::warn!("hub cache dir unavailable: {e}");
458 return;
459 }
460 };
461 if let Err(e) = std::fs::create_dir_all(&dir) {
462 tracing::warn!("failed to create hub cache dir: {e}");
463 return;
464 }
465 let path = dir.join(format!("{}.json", cache_key(url)));
466 match serde_json::to_string_pretty(index) {
467 Ok(json) => {
468 if let Err(e) = std::fs::write(&path, json) {
469 tracing::warn!("failed to write hub cache {}: {e}", path.display());
470 }
471 }
472 Err(e) => tracing::warn!("failed to serialize hub cache: {e}"),
473 }
474}
475
476fn fetch_one(url: &str) -> Result<HubIndex, String> {
480 if let Some(cached) = load_cached(url) {
481 return Ok(cached);
482 }
483
484 let agent = ureq::Agent::new_with_config(
485 ureq::config::Config::builder()
486 .timeout_global(Some(HTTP_TIMEOUT))
487 .build(),
488 );
489 let body: String = agent
490 .get(url)
491 .call()
492 .map_err(|e| format!("Failed to fetch {url}: {e}"))?
493 .body_mut()
494 .read_to_string()
495 .map_err(|e| format!("Failed to read response from {url}: {e}"))?;
496
497 let index: HubIndex = serde_json::from_str(&body)
498 .map_err(|e| format!("Failed to parse index from {url}: {e}"))?;
499
500 save_cached(url, &index);
501 Ok(index)
502}
503
504fn fetch_remote_indices() -> (HubIndex, Vec<String>) {
507 let urls = discover_index_urls();
508 let mut all_packages: Vec<IndexEntry> = Vec::new();
509 let mut seen_names: HashSet<String> = HashSet::new();
510 let mut warnings: Vec<String> = Vec::new();
511
512 for url in &urls {
513 match fetch_one(url) {
514 Ok(index) => {
515 for entry in index.packages {
516 if seen_names.insert(entry.name.clone()) {
517 all_packages.push(entry);
518 }
519 }
521 }
522 Err(e) => {
523 warnings.push(e);
524 }
525 }
526 }
527
528 if all_packages.is_empty() && !warnings.is_empty() {
529 warnings.insert(
530 0,
531 "all remote indices unavailable, showing local packages only".to_string(),
532 );
533 }
534
535 let merged = HubIndex {
536 schema_version: "hub_index/v0".into(),
537 updated_at: String::new(),
538 packages: all_packages,
539 };
540 (merged, warnings)
541}
542
543fn installed_packages() -> HashMap<String, Option<String>> {
548 let mut map = HashMap::new();
549
550 if let Ok(m) = manifest::load_manifest() {
552 for (name, entry) in &m.packages {
553 map.insert(name.clone(), entry.version.clone());
554 }
555 }
556
557 if let Some(home) = dirs::home_dir() {
559 let pkg_dir = home.join(".algocline").join("packages");
560 if let Ok(entries) = std::fs::read_dir(&pkg_dir) {
561 for entry in entries.flatten() {
562 if entry.path().is_dir() {
563 if let Some(name) = entry.file_name().to_str() {
564 map.entry(name.to_string()).or_insert(None);
565 }
566 }
567 }
568 }
569 }
570
571 map
572}
573
574fn local_card_counts() -> HashMap<String, usize> {
576 let mut map = HashMap::new();
577 let home = match dirs::home_dir() {
578 Some(h) => h,
579 None => return map,
580 };
581 let cards_dir = home.join(".algocline").join("cards");
582 let entries = match std::fs::read_dir(&cards_dir) {
583 Ok(e) => e,
584 Err(_) => return map,
585 };
586 for entry in entries.flatten() {
587 if !entry.path().is_dir() {
588 continue;
589 }
590 let pkg = match entry.file_name().to_str() {
591 Some(n) => n.to_string(),
592 None => continue,
593 };
594 let count = std::fs::read_dir(entry.path())
595 .map(|es| {
596 es.flatten()
597 .filter(|e| e.path().extension().is_some_and(|ext| ext == "toml"))
598 .count()
599 })
600 .unwrap_or(0);
601 if count > 0 {
602 map.insert(pkg, count);
603 }
604 }
605 map
606}
607
608fn count_evals_for_pkg(pkg: &str) -> usize {
613 let home = match dirs::home_dir() {
614 Some(h) => h,
615 None => return 0,
616 };
617 let evals_dir = home.join(".algocline").join("evals");
618 let entries = match std::fs::read_dir(&evals_dir) {
619 Ok(e) => e,
620 Err(_) => return 0,
621 };
622
623 let mut meta_stems: HashSet<String> = HashSet::new();
626 let mut meta_matches: usize = 0;
627 let mut non_meta_paths: Vec<(PathBuf, String)> = Vec::new(); for entry in entries.flatten() {
630 let path = entry.path();
631 let name = match path.file_name().and_then(|n| n.to_str()) {
632 Some(n) => n.to_string(),
633 None => continue,
634 };
635
636 if name.ends_with(".meta.json") {
637 let stem = name.trim_end_matches(".meta.json").to_string();
638 meta_stems.insert(stem);
639 if let Ok(content) = std::fs::read_to_string(&path) {
640 if let Ok(val) = serde_json::from_str::<serde_json::Value>(&content) {
641 if val.get("strategy").and_then(|s| s.as_str()) == Some(pkg) {
642 meta_matches += 1;
643 }
644 }
645 }
646 continue;
647 }
648
649 if !name.ends_with(".json") || name.starts_with("compare_") {
651 continue;
652 }
653
654 let stem = path
655 .file_stem()
656 .and_then(|s| s.to_str())
657 .unwrap_or("")
658 .to_string();
659 non_meta_paths.push((path, stem));
660 }
661
662 let fallback_matches = non_meta_paths
664 .iter()
665 .filter(|(_, stem)| !meta_stems.contains(stem))
666 .filter(|(path, _)| {
667 std::fs::read_to_string(path)
668 .ok()
669 .and_then(|c| serde_json::from_str::<serde_json::Value>(&c).ok())
670 .and_then(|v| v.get("strategy")?.as_str().map(|s| s == pkg))
671 .unwrap_or(false)
672 })
673 .count();
674
675 meta_matches + fallback_matches
676}
677
678fn merge(remote: &HubIndex) -> Vec<SearchResult> {
686 let installed = installed_packages();
687 let card_counts = local_card_counts();
688 let pkg_dir = dirs::home_dir().map(|h| h.join(".algocline").join("packages"));
689
690 let mut seen: HashSet<String> = HashSet::new();
691 let mut results: Vec<SearchResult> = Vec::new();
692
693 for entry in &remote.packages {
694 let is_installed = installed.contains_key(&entry.name);
695 let local_cards = card_counts.get(&entry.name).copied().unwrap_or(0);
696
697 let docstring = if entry.docstring.is_empty() && is_installed {
699 pkg_dir
700 .as_ref()
701 .map(|d| extract_docstring(&d.join(&entry.name).join("init.lua")))
702 .unwrap_or_default()
703 } else {
704 entry.docstring.clone()
705 };
706
707 seen.insert(entry.name.clone());
708 results.push(SearchResult {
709 name: entry.name.clone(),
710 version: entry.version.clone(),
711 description: entry.description.clone(),
712 category: entry.category.clone(),
713 source: entry.source.clone(),
714 installed: is_installed,
715 card_count: if is_installed && local_cards > entry.card_count {
716 local_cards
717 } else {
718 entry.card_count
719 },
720 best_card: entry.best_card.clone(),
721 docstring,
722 docstring_matched: None,
723 });
724 }
725
726 for (name, version) in &installed {
728 if seen.contains(name) {
729 continue;
730 }
731 let docstring = pkg_dir
732 .as_ref()
733 .map(|d| extract_docstring(&d.join(name).join("init.lua")))
734 .unwrap_or_default();
735 results.push(SearchResult {
736 name: name.clone(),
737 version: version.clone().unwrap_or_default(),
738 description: String::new(),
739 category: String::new(),
740 source: String::new(),
741 installed: true,
742 card_count: card_counts.get(name).copied().unwrap_or(0),
743 best_card: None,
744 docstring,
745 docstring_matched: None,
746 });
747 }
748
749 results
750}
751
752fn matches_query(result: &SearchResult, query: &str) -> bool {
755 let q = query.to_lowercase();
756 result.name.to_lowercase().contains(&q)
757 || result.description.to_lowercase().contains(&q)
758 || result.category.to_lowercase().contains(&q)
759 || result.docstring.to_lowercase().contains(&q)
760}
761
762fn extract_docstring(path: &std::path::Path) -> String {
771 let content = match std::fs::read_to_string(path) {
772 Ok(c) => c,
773 Err(_) => return String::new(),
774 };
775 let mut lines = Vec::new();
776 for line in content.lines() {
777 let trimmed = line.trim_start();
778 if let Some(rest) = trimmed.strip_prefix("---") {
779 lines.push(rest.trim().to_string());
780 } else if trimmed.is_empty() {
781 continue;
783 } else {
784 break;
785 }
786 }
787 lines.join("\n")
788}
789
790fn parse_meta_from_init_lua(path: &std::path::Path) -> Option<(String, String, String, String)> {
803 let content = std::fs::read_to_string(path).ok()?;
804 let head = content.as_str();
805
806 let mut search_from = 0;
810 let meta_start = loop {
811 let rel = head[search_from..].find("M.meta")?;
812 let pos = search_from + rel;
813 let line_start = head[..pos].rfind('\n').map(|i| i + 1).unwrap_or(0);
814 if !head[line_start..pos].contains("--") {
815 break pos;
816 }
817 search_from = pos + "M.meta".len();
818 };
819 let brace_start = head[meta_start..].find('{')? + meta_start;
820
821 let mut depth = 0;
823 let mut brace_end = None;
824 for (i, ch) in head[brace_start..].char_indices() {
825 match ch {
826 '{' => depth += 1,
827 '}' => {
828 depth -= 1;
829 if depth == 0 {
830 brace_end = Some(brace_start + i);
831 break;
832 }
833 }
834 _ => {}
835 }
836 }
837 let brace_end = brace_end?;
838 let block = &head[brace_start + 1..brace_end];
839
840 let extract = |field: &str| -> String {
841 let mut search_from = 0;
847 while let Some(rel) = block[search_from..].find(field) {
848 let pos = search_from + rel;
849 let word_boundary = pos == 0 || {
850 let prev = block.as_bytes()[pos - 1];
851 !(prev.is_ascii_alphanumeric() || prev == b'_')
852 };
853 if word_boundary {
854 let after = &block[pos + field.len()..];
855 let mut collected = String::new();
856 let mut cursor = 0usize;
857 let mut found_any = false;
858 loop {
859 let rest = &after[cursor..];
860 let Some(q_start_rel) = rest.find('"') else {
861 break;
862 };
863 if found_any {
864 let between = &rest[..q_start_rel];
869 if between.trim() != ".." {
870 break;
871 }
872 }
873 let lit_start = cursor + q_start_rel + 1;
874 let Some(q_end_rel) = after[lit_start..].find('"') else {
875 break;
876 };
877 collected.push_str(&after[lit_start..lit_start + q_end_rel]);
878 cursor = lit_start + q_end_rel + 1;
879 found_any = true;
880 }
881 if found_any {
882 return collected;
883 }
884 }
885 search_from = pos + field.len();
886 }
887 String::new()
888 };
889
890 let name = extract("name");
891 if name.is_empty() {
892 return None;
893 }
894 Some((
895 name,
896 extract("version"),
897 extract("description"),
898 extract("category"),
899 ))
900}
901
902fn build_index(source_dir: Option<&std::path::Path>) -> HubIndex {
911 let empty = || HubIndex {
912 schema_version: "hub_index/v0".into(),
913 updated_at: super::manifest::now_iso8601(),
914 packages: Vec::new(),
915 };
916
917 let pkg_dir = match source_dir {
918 Some(d) => d.to_path_buf(),
919 None => {
920 let home = match dirs::home_dir() {
921 Some(h) => h,
922 None => return empty(),
923 };
924 home.join(".algocline").join("packages")
925 }
926 };
927
928 let use_local_state = source_dir.is_none();
929 let card_counts = if use_local_state {
930 local_card_counts()
931 } else {
932 HashMap::new()
933 };
934 let manifest = if use_local_state {
935 manifest::load_manifest().unwrap_or_default()
936 } else {
937 manifest::Manifest::default()
938 };
939
940 let mut entries = Vec::new();
941
942 let dir_entries = match std::fs::read_dir(&pkg_dir) {
943 Ok(e) => e,
944 Err(_) => return empty(),
945 };
946
947 for entry in dir_entries.flatten() {
948 if !entry.path().is_dir() {
949 continue;
950 }
951 let dir_name = match entry.file_name().to_str() {
952 Some(n) if !n.starts_with('.') && !n.starts_with('_') => n.to_string(),
953 _ => continue,
954 };
955
956 let init_lua = entry.path().join("init.lua");
957 if !init_lua.exists() {
958 continue;
959 }
960
961 let (name, version, description, category) = parse_meta_from_init_lua(&init_lua)
962 .unwrap_or_else(|| {
963 (
964 dir_name.clone(),
965 String::new(),
966 String::new(),
967 String::new(),
968 )
969 });
970
971 let docstring = extract_docstring(&init_lua);
972
973 let source = manifest
975 .packages
976 .get(&dir_name)
977 .map(|e| e.source.clone())
978 .unwrap_or_default();
979
980 entries.push(IndexEntry {
981 name,
982 version,
983 description,
984 category,
985 source,
986 card_count: card_counts.get(&dir_name).copied().unwrap_or(0),
987 best_card: None,
988 docstring,
989 });
990 }
991
992 entries.sort_by(|a, b| a.name.cmp(&b.name));
993
994 HubIndex {
995 schema_version: "hub_index/v0".into(),
996 updated_at: super::manifest::now_iso8601(),
997 packages: entries,
998 }
999}
1000
1001impl AppService {
1004 pub fn hub_reindex(
1013 &self,
1014 output_path: Option<&str>,
1015 source_dir: Option<&str>,
1016 ) -> Result<String, String> {
1017 let src = source_dir.map(std::path::Path::new);
1018 if let Some(d) = src {
1019 if !d.is_dir() {
1020 return Err(format!("source_dir '{}' is not a directory", d.display()));
1021 }
1022 }
1023 let index = build_index(src);
1024
1025 let written_path = if let Some(path) = output_path {
1026 let json = serde_json::to_string_pretty(&index)
1027 .map_err(|e| format!("Failed to serialize index: {e}"))?;
1028 std::fs::write(path, &json)
1029 .map_err(|e| format!("Failed to write index to {path}: {e}"))?;
1030 Some(path.to_string())
1031 } else {
1032 None
1033 };
1034
1035 let response = serde_json::json!({
1036 "package_count": index.packages.len(),
1037 "updated_at": index.updated_at,
1038 "output_path": written_path,
1039 "source_dir": source_dir,
1040 });
1041 Ok(response.to_string())
1042 }
1043
1044 pub fn hub_info(&self, pkg: &str) -> Result<String, String> {
1049 use algocline_engine::card;
1050
1051 if pkg.contains("..") || pkg.contains('/') || pkg.contains('\\') {
1053 return Err(format!("Invalid package name: '{pkg}'"));
1054 }
1055
1056 let installed = installed_packages();
1058 let is_installed = installed.contains_key(pkg);
1059
1060 let (version, description, category, source) = {
1061 let (remote, _) = fetch_remote_indices();
1063 if let Some(entry) = remote.packages.iter().find(|e| e.name == pkg) {
1064 (
1065 entry.version.clone(),
1066 entry.description.clone(),
1067 entry.category.clone(),
1068 entry.source.clone(),
1069 )
1070 } else if is_installed {
1071 let home = dirs::home_dir().ok_or("Cannot determine home directory")?;
1073 let init_lua = home
1074 .join(".algocline")
1075 .join("packages")
1076 .join(pkg)
1077 .join("init.lua");
1078 let meta = parse_meta_from_init_lua(&init_lua);
1079 let manifest_source = manifest::load_manifest()
1080 .ok()
1081 .and_then(|m| m.packages.get(pkg).map(|e| e.source.clone()))
1082 .unwrap_or_default();
1083 match meta {
1084 Some((_, v, d, c)) => (v, d, c, manifest_source),
1085 None => (
1086 installed.get(pkg).cloned().flatten().unwrap_or_default(),
1087 String::new(),
1088 String::new(),
1089 manifest_source,
1090 ),
1091 }
1092 } else {
1093 return Err(format!(
1094 "Package '{pkg}' not found in remote indices or locally installed packages"
1095 ));
1096 }
1097 };
1098
1099 let card_rows = card::list(Some(pkg)).unwrap_or_default();
1101 let cards_json = card::summaries_to_json(&card_rows);
1102
1103 let aliases_json = match card::alias_list(Some(pkg)) {
1105 Ok(rows) => card::aliases_to_json(&rows),
1106 Err(_) => serde_json::json!([]),
1107 };
1108
1109 let card_count = card_rows.len();
1111 let best_pass_rate = card_rows
1112 .iter()
1113 .filter_map(|c| c.pass_rate)
1114 .fold(f64::NEG_INFINITY, f64::max);
1115 let best_pass_rate = if best_pass_rate.is_finite() {
1116 Some(best_pass_rate)
1117 } else {
1118 None
1119 };
1120
1121 let eval_count = count_evals_for_pkg(pkg);
1123
1124 let response = serde_json::json!({
1125 "pkg": {
1126 "name": pkg,
1127 "version": version,
1128 "description": description,
1129 "category": category,
1130 "source": source,
1131 "installed": is_installed,
1132 },
1133 "cards": cards_json,
1134 "aliases": aliases_json,
1135 "stats": {
1136 "card_count": card_count,
1137 "eval_count": eval_count,
1138 "best_pass_rate": best_pass_rate,
1139 },
1140 });
1141 Ok(response.to_string())
1142 }
1143
1144 pub(crate) fn hub_search(
1176 &self,
1177 query: Option<&str>,
1178 category: Option<&str>,
1179 installed_only: Option<bool>,
1180 opts: ListOpts,
1181 ) -> Result<String, String> {
1182 let (remote, warnings) = fetch_remote_indices();
1183 let mut results = merge(&remote);
1184
1185 let query_lower = query.filter(|q| !q.is_empty()).map(|q| q.to_lowercase());
1188 if let Some(ref ql) = query_lower {
1189 results.retain(|r| matches_query(r, ql));
1190 }
1191
1192 if let Some(ref ql) = query_lower {
1196 for r in &mut results {
1197 let other_hit = r.name.to_lowercase().contains(ql)
1198 || r.description.to_lowercase().contains(ql)
1199 || r.category.to_lowercase().contains(ql);
1200 let doc_hit = r.docstring.to_lowercase().contains(ql);
1201 r.docstring_matched = if !other_hit && doc_hit {
1202 Some(true)
1203 } else {
1204 None
1205 };
1206 }
1207 }
1208
1209 let mut filter_map: std::collections::HashMap<String, serde_json::Value> =
1213 opts.filter.unwrap_or_default();
1214 if let Some(cat) = category {
1215 filter_map
1216 .entry("category".to_string())
1217 .or_insert_with(|| serde_json::Value::String(cat.to_string()));
1218 }
1219 if let Some(only) = installed_only {
1220 if only {
1224 filter_map
1225 .entry("installed".to_string())
1226 .or_insert(serde_json::Value::Bool(true));
1227 }
1228 }
1229
1230 let sort_str = opts.sort.as_deref().unwrap_or("-installed,name");
1233 let sort_keys = parse_sort(sort_str)?;
1234
1235 let fields = resolve_fields(
1238 opts.verbose.as_deref(),
1239 opts.fields.as_deref(),
1240 HUB_SEARCH_SUMMARY,
1241 HUB_SEARCH_FULL,
1242 )?;
1243 let include_docstring = fields.iter().any(|f| f == "docstring");
1244
1245 let mut items: Vec<serde_json::Value> = results
1248 .iter()
1249 .map(|r| r.to_value_with_optional_docstring(include_docstring))
1250 .collect();
1251
1252 if !filter_map.is_empty() {
1255 items.retain(|v| matches_filter(v, &filter_map));
1256 }
1257
1258 apply_sort_by_value(&mut items, &sort_keys);
1260
1261 let total = items.len();
1265 let limit = opts.limit.unwrap_or(50);
1266 if limit > 0 {
1267 items.truncate(limit);
1268 }
1269
1270 let projected: Vec<serde_json::Value> = items
1273 .into_iter()
1274 .map(|v| project_fields(v, &fields))
1275 .collect();
1276
1277 let sources = discover_index_urls();
1279
1280 let mut json = serde_json::json!({
1281 "results": projected,
1282 "total": total,
1283 "sources": sources,
1284 });
1285 if !warnings.is_empty() {
1286 json["warnings"] = serde_json::json!(warnings);
1287 }
1288 Ok(json.to_string())
1289 }
1290}
1291
1292#[cfg(test)]
1293mod tests {
1294 use super::*;
1295
1296 #[test]
1297 fn repo_to_index_url_github() {
1298 assert_eq!(
1299 repo_to_index_url("https://github.com/ynishi/algocline-bundled-packages"),
1300 Some(
1301 "https://raw.githubusercontent.com/ynishi/algocline-bundled-packages/main/hub_index.json"
1302 .to_string()
1303 )
1304 );
1305 }
1306
1307 #[test]
1308 fn repo_to_index_url_github_trailing_slash() {
1309 assert_eq!(
1310 repo_to_index_url("https://github.com/user/repo/"),
1311 Some("https://raw.githubusercontent.com/user/repo/main/hub_index.json".to_string())
1312 );
1313 }
1314
1315 #[test]
1316 fn repo_to_index_url_github_dot_git() {
1317 assert_eq!(
1318 repo_to_index_url("https://github.com/user/repo.git"),
1319 Some("https://raw.githubusercontent.com/user/repo/main/hub_index.json".to_string())
1320 );
1321 }
1322
1323 #[test]
1324 fn repo_to_index_url_direct_json() {
1325 assert_eq!(
1326 repo_to_index_url("https://example.com/my_index.json"),
1327 Some("https://example.com/my_index.json".to_string())
1328 );
1329 }
1330
1331 #[test]
1332 fn repo_to_index_url_unknown_host_no_json() {
1333 assert_eq!(repo_to_index_url("https://example.com/some-repo"), None);
1334 }
1335
1336 #[test]
1337 fn repo_to_index_url_local_path() {
1338 assert_eq!(repo_to_index_url("/home/user/my-pkg"), None);
1339 }
1340
1341 #[test]
1342 fn cache_key_stable() {
1343 let k1 = cache_key("https://example.com/index.json");
1344 let k2 = cache_key("https://example.com/index.json");
1345 assert_eq!(k1, k2);
1346 assert_eq!(k1.len(), 16); }
1348
1349 #[test]
1350 fn cache_key_different_urls() {
1351 let k1 = cache_key("https://a.com/index.json");
1352 let k2 = cache_key("https://b.com/index.json");
1353 assert_ne!(k1, k2);
1354 }
1355
1356 #[test]
1357 fn parse_meta_flat() {
1358 let tmp = tempfile::tempdir().unwrap();
1359 let path = tmp.path().join("init.lua");
1360 std::fs::write(
1361 &path,
1362 r#"
1363local M = {}
1364M.meta = {
1365 name = "my_pkg",
1366 version = "1.0.0",
1367 description = "A test package",
1368 category = "reasoning",
1369}
1370return M
1371"#,
1372 )
1373 .unwrap();
1374
1375 let result = parse_meta_from_init_lua(&path).unwrap();
1376 assert_eq!(result.0, "my_pkg");
1377 assert_eq!(result.1, "1.0.0");
1378 assert_eq!(result.2, "A test package");
1379 assert_eq!(result.3, "reasoning");
1380 }
1381
1382 #[test]
1383 fn parse_meta_nested_table() {
1384 let tmp = tempfile::tempdir().unwrap();
1385 let path = tmp.path().join("init.lua");
1386 std::fs::write(
1387 &path,
1388 r#"
1389local M = {}
1390M.meta = {
1391 name = "nested_pkg",
1392 tags = { "a", "b" },
1393 description = "After nested",
1394}
1395return M
1396"#,
1397 )
1398 .unwrap();
1399
1400 let result = parse_meta_from_init_lua(&path).unwrap();
1401 assert_eq!(result.0, "nested_pkg");
1402 assert_eq!(result.2, "After nested");
1403 }
1404
1405 #[test]
1409 #[ignore]
1410 fn parse_meta_real_bundled_packages() {
1411 let Ok(root) = std::env::var("BUNDLED_PACKAGES_DIR") else {
1412 panic!("set BUNDLED_PACKAGES_DIR=/path/to/algocline-bundled-packages");
1413 };
1414 let root = std::path::Path::new(&root);
1415 let mut total = 0usize;
1416 let mut failed_parse: Vec<String> = Vec::new();
1417 let mut empty_desc: Vec<String> = Vec::new();
1418 for entry in std::fs::read_dir(root).unwrap().flatten() {
1419 if !entry.path().is_dir() {
1420 continue;
1421 }
1422 let name = entry.file_name().to_string_lossy().to_string();
1423 if name.starts_with('.') || name.starts_with('_') {
1424 continue;
1425 }
1426 let init_lua = entry.path().join("init.lua");
1427 if !init_lua.exists() {
1428 continue;
1429 }
1430 total += 1;
1431 match parse_meta_from_init_lua(&init_lua) {
1432 Some((_n, _v, desc, _c)) => {
1433 if desc.is_empty() {
1434 empty_desc.push(name);
1435 }
1436 }
1437 None => failed_parse.push(name),
1438 }
1439 }
1440 assert!(total >= 100, "expected ≥100 pkgs, got {total}");
1441 assert!(
1442 failed_parse.is_empty(),
1443 "parse_meta returned None for {} pkgs: {:?}",
1444 failed_parse.len(),
1445 failed_parse
1446 );
1447 assert!(
1448 empty_desc.is_empty(),
1449 "empty description for {} pkgs: {:?}",
1450 empty_desc.len(),
1451 empty_desc
1452 );
1453 }
1454
1455 #[test]
1456 fn parse_meta_concat_string_literals() {
1457 let tmp = tempfile::tempdir().unwrap();
1459 let path = tmp.path().join("init.lua");
1460 std::fs::write(
1461 &path,
1462 r#"
1463local M = {}
1464M.meta = {
1465 name = "concat_pkg",
1466 version = "0.1.0",
1467 description = "Adaptive Branching MCTS — Thompson Sampling with dynamic "
1468 .. "wider/deeper decisions. GEN node mechanism for principled branching. "
1469 .. "Consistently outperforms standard MCTS and repeated sampling.",
1470 category = "reasoning",
1471}
1472return M
1473"#,
1474 )
1475 .unwrap();
1476
1477 let result = parse_meta_from_init_lua(&path).unwrap();
1478 assert_eq!(result.0, "concat_pkg");
1479 assert_eq!(result.1, "0.1.0");
1480 assert_eq!(
1481 result.2,
1482 "Adaptive Branching MCTS — Thompson Sampling with dynamic \
1483 wider/deeper decisions. GEN node mechanism for principled branching. \
1484 Consistently outperforms standard MCTS and repeated sampling."
1485 );
1486 assert_eq!(result.3, "reasoning");
1487 }
1488
1489 #[test]
1490 fn parse_meta_large_leading_docstring() {
1491 let tmp = tempfile::tempdir().unwrap();
1493 let path = tmp.path().join("init.lua");
1494 let mut content = String::new();
1495 for i in 0..120 {
1497 content.push_str(&format!(
1498 "--- line {i}: this is a long documentation comment to push M.meta beyond the old 2KB scan window\n"
1499 ));
1500 }
1501 content.push_str(
1502 r#"
1503local M = {}
1504M.meta = {
1505 name = "late_meta_pkg",
1506 version = "0.2.0",
1507 description = "Located past 2KB",
1508 category = "test",
1509}
1510return M
1511"#,
1512 );
1513 std::fs::write(&path, &content).unwrap();
1514 assert!(content.len() > 2048, "fixture should exceed 2KB");
1515
1516 let result = parse_meta_from_init_lua(&path).unwrap();
1517 assert_eq!(result.0, "late_meta_pkg");
1518 assert_eq!(result.1, "0.2.0");
1519 assert_eq!(result.2, "Located past 2KB");
1520 assert_eq!(result.3, "test");
1521 }
1522
1523 #[test]
1524 fn parse_meta_word_boundary() {
1525 let tmp = tempfile::tempdir().unwrap();
1526 let path = tmp.path().join("init.lua");
1527 std::fs::write(
1528 &path,
1529 r#"
1530local M = {}
1531M.meta = {
1532 name = "wb_pkg",
1533 short_description = "should not match",
1534 description = "correct one",
1535}
1536return M
1537"#,
1538 )
1539 .unwrap();
1540
1541 let result = parse_meta_from_init_lua(&path).unwrap();
1542 assert_eq!(result.0, "wb_pkg");
1543 assert_eq!(result.2, "correct one");
1544 }
1545
1546 #[test]
1547 fn merge_dedup_uses_hashset() {
1548 let remote = HubIndex {
1551 schema_version: "hub_index/v0".into(),
1552 updated_at: String::new(),
1553 packages: vec![IndexEntry {
1554 name: "remote_only".into(),
1555 version: "1.0".into(),
1556 description: "from remote".into(),
1557 category: "test".into(),
1558 source: String::new(),
1559 card_count: 0,
1560 best_card: None,
1561 docstring: String::new(),
1562 }],
1563 };
1564
1565 let results = merge(&remote);
1566 assert!(results.iter().any(|r| r.name == "remote_only"));
1568 }
1569
1570 #[test]
1571 fn extract_docstring_collects_leading_comments() {
1572 let tmp = tempfile::tempdir().unwrap();
1573 let path = tmp.path().join("init.lua");
1574 std::fs::write(
1575 &path,
1576 r#"--- cascade — Multi-level difficulty routing with confidence gating
1577--- Based on: "FrugalGPT" (Chen et al., 2023)
1578--- Uses Thompson Sampling for budget allocation.
1579
1580local M = {}
1581M.meta = { name = "cascade" }
1582return M
1583"#,
1584 )
1585 .unwrap();
1586
1587 let doc = extract_docstring(&path);
1588 assert!(doc.contains("FrugalGPT"), "should contain paper ref");
1589 assert!(
1590 doc.contains("Thompson Sampling"),
1591 "should contain technique"
1592 );
1593 assert!(!doc.contains("local M"), "should not contain code");
1594 }
1595
1596 #[test]
1597 fn extract_docstring_empty_when_no_comments() {
1598 let tmp = tempfile::tempdir().unwrap();
1599 let path = tmp.path().join("init.lua");
1600 std::fs::write(&path, "local M = {}\nreturn M\n").unwrap();
1601
1602 let doc = extract_docstring(&path);
1603 assert!(doc.is_empty());
1604 }
1605
1606 #[test]
1607 fn matches_query_searches_docstring() {
1608 let result = SearchResult {
1609 name: "cascade".into(),
1610 version: "0.1.0".into(),
1611 description: "Multi-level routing".into(),
1612 category: "meta".into(),
1613 source: String::new(),
1614 installed: true,
1615 card_count: 0,
1616 best_card: None,
1617 docstring: "Based on FrugalGPT. Uses Thompson Sampling.".into(),
1618 docstring_matched: None,
1619 };
1620
1621 assert!(matches_query(&result, "thompson"), "docstring match");
1622 assert!(matches_query(&result, "FrugalGPT"), "docstring match case");
1623 assert!(matches_query(&result, "routing"), "description match");
1624 assert!(!matches_query(&result, "bayesian"), "no match");
1625 }
1626
1627 fn sample_search_result() -> SearchResult {
1635 SearchResult {
1636 name: "cascade".into(),
1637 version: "0.1.0".into(),
1638 description: "Multi-level routing".into(),
1639 category: "reasoning".into(),
1640 source: "https://example.com/cascade".into(),
1641 installed: true,
1642 card_count: 3,
1643 best_card: None,
1644 docstring: "Based on FrugalGPT. Uses Thompson Sampling.".into(),
1645 docstring_matched: None,
1646 }
1647 }
1648
1649 #[test]
1650 fn to_value_default_omits_docstring() {
1651 let r = sample_search_result();
1652 let v = r.to_value_with_optional_docstring(false);
1653 let obj = v.as_object().expect("object");
1654 assert!(
1655 !obj.contains_key("docstring"),
1656 "default summary must not leak docstring"
1657 );
1658 assert_eq!(obj.get("name").and_then(|x| x.as_str()), Some("cascade"));
1659 assert!(
1662 !obj.contains_key("docstring_matched"),
1663 "docstring_matched=None must be omitted"
1664 );
1665 }
1666
1667 #[test]
1668 fn to_value_include_reattaches_docstring() {
1669 let r = sample_search_result();
1670 let v = r.to_value_with_optional_docstring(true);
1671 let obj = v.as_object().expect("object");
1672 assert_eq!(
1673 obj.get("docstring").and_then(|x| x.as_str()),
1674 Some("Based on FrugalGPT. Uses Thompson Sampling.")
1675 );
1676 }
1677
1678 #[test]
1679 fn to_value_serializes_docstring_matched_when_set() {
1680 let mut r = sample_search_result();
1681 r.docstring_matched = Some(true);
1682 let v = r.to_value_with_optional_docstring(false);
1683 let obj = v.as_object().expect("object");
1684 assert_eq!(
1685 obj.get("docstring_matched").and_then(|x| x.as_bool()),
1686 Some(true)
1687 );
1688 }
1689
1690 #[test]
1700 fn hub_search_default_summary_excludes_docstring() {
1701 let r = sample_search_result();
1702 let fields = resolve_fields(None, None, HUB_SEARCH_SUMMARY, HUB_SEARCH_FULL).unwrap();
1703 let include_docstring = fields.iter().any(|f| f == "docstring");
1704 let v = project_fields(
1705 r.to_value_with_optional_docstring(include_docstring),
1706 &fields,
1707 );
1708 let obj = v.as_object().expect("object");
1709 assert!(
1710 !obj.contains_key("docstring"),
1711 "summary preset must omit docstring"
1712 );
1713 for key in ["name", "version", "description", "category", "installed"] {
1715 assert!(obj.contains_key(key), "summary preset key {key} missing");
1716 }
1717 }
1718
1719 #[test]
1720 fn hub_search_verbose_full_includes_docstring() {
1721 let r = sample_search_result();
1722 let fields =
1723 resolve_fields(Some("full"), None, HUB_SEARCH_SUMMARY, HUB_SEARCH_FULL).unwrap();
1724 let include_docstring = fields.iter().any(|f| f == "docstring");
1725 let v = project_fields(
1726 r.to_value_with_optional_docstring(include_docstring),
1727 &fields,
1728 );
1729 let obj = v.as_object().expect("object");
1730 assert_eq!(
1731 obj.get("docstring").and_then(|x| x.as_str()),
1732 Some("Based on FrugalGPT. Uses Thompson Sampling.")
1733 );
1734 for key in ["source", "card_count"] {
1736 assert!(obj.contains_key(key), "full preset key {key} missing");
1737 }
1738 }
1739
1740 #[test]
1741 fn hub_search_fields_beats_verbose() {
1742 let r = sample_search_result();
1743 let explicit = vec!["name".to_string(), "docstring".to_string()];
1744 let fields = resolve_fields(
1747 Some("summary"),
1748 Some(&explicit),
1749 HUB_SEARCH_SUMMARY,
1750 HUB_SEARCH_FULL,
1751 )
1752 .unwrap();
1753 let include_docstring = fields.iter().any(|f| f == "docstring");
1754 let v = project_fields(
1755 r.to_value_with_optional_docstring(include_docstring),
1756 &fields,
1757 );
1758 let obj = v.as_object().expect("object");
1759 assert_eq!(obj.len(), 2, "only the two requested fields");
1760 assert!(obj.contains_key("name"));
1761 assert!(obj.contains_key("docstring"));
1762 }
1763
1764 #[test]
1765 fn hub_search_fields_unknown_key_silently_skipped() {
1766 let r = sample_search_result();
1767 let explicit = vec!["name".to_string(), "bogus".to_string()];
1768 let fields =
1769 resolve_fields(None, Some(&explicit), HUB_SEARCH_SUMMARY, HUB_SEARCH_FULL).unwrap();
1770 let v = project_fields(r.to_value_with_optional_docstring(false), &fields);
1771 let obj = v.as_object().expect("object");
1772 assert_eq!(obj.len(), 1, "bogus must not appear");
1773 assert!(obj.contains_key("name"));
1774 }
1775
1776 #[test]
1777 fn hub_search_invalid_verbose_errors() {
1778 let err =
1779 resolve_fields(Some("fat"), None, HUB_SEARCH_SUMMARY, HUB_SEARCH_FULL).unwrap_err();
1780 assert!(
1781 err.contains("fat"),
1782 "error must mention the offending value"
1783 );
1784 }
1785
1786 fn classify(r: &SearchResult, query: &str) -> Option<bool> {
1795 let ql = query.to_lowercase();
1796 if query.is_empty() {
1797 return None;
1798 }
1799 let other_hit = r.name.to_lowercase().contains(&ql)
1800 || r.description.to_lowercase().contains(&ql)
1801 || r.category.to_lowercase().contains(&ql);
1802 let doc_hit = r.docstring.to_lowercase().contains(&ql);
1803 if !other_hit && doc_hit {
1804 Some(true)
1805 } else {
1806 None
1807 }
1808 }
1809
1810 #[test]
1811 fn docstring_matched_true_when_only_docstring_hits() {
1812 let r = sample_search_result();
1813 assert_eq!(classify(&r, "thompson"), Some(true));
1815 }
1816
1817 #[test]
1818 fn docstring_matched_none_when_name_also_hits() {
1819 let r = sample_search_result();
1820 assert_eq!(classify(&r, "cascade"), None);
1822 }
1823
1824 #[test]
1825 fn docstring_matched_none_when_description_hits() {
1826 let r = sample_search_result();
1827 assert_eq!(classify(&r, "routing"), None);
1829 }
1830
1831 #[test]
1832 fn docstring_matched_none_when_query_empty() {
1833 let r = sample_search_result();
1834 assert_eq!(classify(&r, ""), None);
1835 }
1836
1837 fn build_filter_map(
1845 category: Option<&str>,
1846 installed_only: Option<bool>,
1847 explicit: Option<HashMap<String, serde_json::Value>>,
1848 ) -> HashMap<String, serde_json::Value> {
1849 let mut filter_map = explicit.unwrap_or_default();
1850 if let Some(cat) = category {
1851 filter_map
1852 .entry("category".to_string())
1853 .or_insert_with(|| serde_json::Value::String(cat.to_string()));
1854 }
1855 if let Some(only) = installed_only {
1856 if only {
1857 filter_map
1858 .entry("installed".to_string())
1859 .or_insert(serde_json::Value::Bool(true));
1860 }
1861 }
1862 filter_map
1863 }
1864
1865 #[test]
1866 fn filter_by_category_via_legacy_param() {
1867 let m = build_filter_map(Some("reasoning"), None, None);
1868 assert_eq!(
1869 m.get("category"),
1870 Some(&serde_json::Value::String("reasoning".to_string()))
1871 );
1872 }
1873
1874 #[test]
1875 fn filter_by_installed_only_via_legacy_param() {
1876 let m = build_filter_map(None, Some(true), None);
1877 assert_eq!(m.get("installed"), Some(&serde_json::Value::Bool(true)));
1878 }
1879
1880 #[test]
1881 fn filter_installed_only_false_is_noop() {
1882 let m = build_filter_map(None, Some(false), None);
1883 assert!(
1884 !m.contains_key("installed"),
1885 "installed_only=false should not fold in"
1886 );
1887 }
1888
1889 #[test]
1890 fn filter_beats_legacy_param_on_conflict() {
1891 let mut explicit = HashMap::new();
1894 explicit.insert(
1895 "category".to_string(),
1896 serde_json::Value::String("meta".to_string()),
1897 );
1898 let m = build_filter_map(Some("reasoning"), None, Some(explicit));
1899 assert_eq!(
1900 m.get("category"),
1901 Some(&serde_json::Value::String("meta".to_string()))
1902 );
1903 }
1904
1905 #[test]
1906 fn filter_merges_legacy_when_no_conflict() {
1907 let mut explicit = HashMap::new();
1910 explicit.insert("installed".to_string(), serde_json::Value::Bool(true));
1911 let m = build_filter_map(Some("reasoning"), None, Some(explicit));
1912 assert_eq!(
1913 m.get("category"),
1914 Some(&serde_json::Value::String("reasoning".to_string()))
1915 );
1916 assert_eq!(m.get("installed"), Some(&serde_json::Value::Bool(true)));
1917 }
1918
1919 #[test]
1922 fn default_sort_is_minus_installed_name() {
1923 let keys = parse_sort("-installed,name").unwrap();
1924 assert_eq!(keys.len(), 2);
1925 assert_eq!(keys[0].key, "installed");
1926 assert!(keys[0].desc, "installed must sort desc (true first)");
1927 assert_eq!(keys[1].key, "name");
1928 assert!(!keys[1].desc);
1929
1930 let mut items = vec![
1932 serde_json::json!({"installed": false, "name": "zeta"}),
1933 serde_json::json!({"installed": true, "name": "mu"}),
1934 serde_json::json!({"installed": false, "name": "alpha"}),
1935 serde_json::json!({"installed": true, "name": "beta"}),
1936 ];
1937 apply_sort_by_value(&mut items, &keys);
1938 let names: Vec<&str> = items
1939 .iter()
1940 .map(|v| v.get("name").and_then(|x| x.as_str()).unwrap_or(""))
1941 .collect();
1942 assert_eq!(names, vec!["beta", "mu", "alpha", "zeta"]);
1943 }
1944}