Skip to main content

algocline_app/service/
hub.rs

1//! Hub — package discovery, search, and index management.
2//!
3//! The Hub is algocline's package registry layer.  It aggregates remote
4//! index data with local install state so that users (via AI) can
5//! **discover** packages they haven't installed yet, and **inspect**
6//! installed packages with full Card and eval statistics.
7//!
8//! ## Staged design
9//!
10//! | Stage | Scope | Status |
11//! |-------|-------|--------|
12//! | **1** | Card Collection install, Pkg-bundled cards | Done |
13//! | **2** | Hub MCP tools (`hub_search`, `hub_info`, `hub_reindex`), local index | Done |
14//! | **3** | Aggregated remote collection index, `hub_publish`, LP | Planned |
15//!
16//! ## MCP tools
17//!
18//! | Tool | Description |
19//! |------|-------------|
20//! | `alc_hub_search` | Discover packages across remote + local indices |
21//! | `alc_hub_info` | Detailed single-package view (meta + cards + aliases + stats) |
22//! | `alc_hub_reindex` | Rebuild index from local packages or a repo checkout |
23//!
24//! ## Index schema (`hub_index/v0`)
25//!
26//! ```json
27//! {
28//!   "schema_version": "hub_index/v0",
29//!   "updated_at": "2026-04-12T10:00:00Z",
30//!   "packages": [{
31//!     "name": "cot",
32//!     "version": "0.1.0",
33//!     "description": "Chain-of-Thought prompting",
34//!     "category": "reasoning",
35//!     "source": "https://github.com/...",
36//!     "card_count": 3,
37//!     "best_card": { "card_id": "...", "model": "...", "pass_rate": 0.82, "scenario": "..." }
38//!   }]
39//! }
40//! ```
41//!
42//! Index generation uses `init.lua` M.meta parsing only — no Lua VM
43//! required.  This keeps the index buildable in CI environments.
44//!
45//! ## Index URL discovery (4-tier)
46//!
47//! Sources are checked in priority order; URLs are deduplicated:
48//!
49//!   0. **Collection URL** — `[hub].collection_url` in `~/.algocline/config.toml`.
50//!      Aggregated index containing all known packages (Stage 3).
51//!   1. **Hub registries** — `~/.algocline/hub_registries.json`, auto-populated
52//!      by `pkg_install` and `card_install`.
53//!   2. **Installed manifest** — `~/.algocline/installed.json`, fallback for
54//!      sources registered before registries existed.
55//!   3. **Compiled-in seeds** — `AUTO_INSTALL_SOURCES` for first-run bootstrap.
56//!
57//! GitHub repo URLs are transformed to raw index URLs:
58//!
59//! ```text
60//! https://github.com/{owner}/{repo}
61//!   → https://raw.githubusercontent.com/{owner}/{repo}/main/hub_index.json
62//! ```
63//!
64//! ## Caching
65//!
66//! Remote indices are cached per-source at
67//! `~/.algocline/hub_cache/{hash}.json` where hash is FNV-1a of the
68//! URL.  TTL is 1 hour.
69//!
70//! ## Registry persistence
71//!
72//! `~/.algocline/hub_registries.json` records source URLs from
73//! `pkg_install` and `card_install`.  Written atomically (tempfile +
74//! rename) to avoid corruption on interruption.
75
76use 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
85// ─── Constants ─────────────────────────────────────────────────
86
87/// Cache TTL in seconds (1 hour).
88const CACHE_TTL_SECS: u64 = 3600;
89
90/// HTTP request timeout (30 seconds).
91const HTTP_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(30);
92
93// ─── Index schema ──────────────────────────────────────────────
94
95/// Remote index — same shape as the local index so merge is trivial.
96#[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/// One package in the index.
106#[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    /// Leading `---` docstring lines from init.lua (for full-text search).
122    #[serde(default)]
123    pub docstring: String,
124}
125
126/// Best card summary within a package.
127#[derive(Debug, Clone, Serialize, Deserialize)]
128pub(crate) struct BestCard {
129    pub card_id: String,
130    #[serde(default)]
131    pub model: String,
132    #[serde(default)]
133    pub pass_rate: f64,
134    #[serde(default)]
135    pub scenario: String,
136}
137
138/// Search result — index entry enriched with local install state.
139#[derive(Debug, Clone, Serialize)]
140struct SearchResult {
141    name: String,
142    version: String,
143    description: String,
144    category: String,
145    source: String,
146    installed: bool,
147    card_count: usize,
148    best_card: Option<BestCard>,
149    #[serde(default, skip_serializing_if = "String::is_empty")]
150    docstring: String,
151}
152
153// ─── Hub registries ───────────────────────────────────────────
154//
155// Persistent file (`~/.algocline/hub_registries.json`) that records
156// source URLs from `pkg_install` and `card_install`.  This is the
157// primary source for Hub index URL discovery — the manifest and
158// `AUTO_INSTALL_SOURCES` serve as fallback seeds.
159
160/// One entry in `hub_registries.json`.
161#[derive(Debug, Clone, Serialize, Deserialize)]
162pub(crate) struct RegistryEntry {
163    /// Original source URL (Git repo or local path).
164    pub source: String,
165    /// How it was registered: "pkg_install" or "card_install".
166    pub origin: String,
167    /// ISO 8601 timestamp of when the entry was added.
168    pub added_at: String,
169}
170
171/// Top-level registries file.
172#[derive(Debug, Clone, Serialize, Deserialize, Default)]
173pub(crate) struct HubRegistries {
174    pub registries: Vec<RegistryEntry>,
175}
176
177fn registries_path() -> Result<PathBuf, String> {
178    let home = dirs::home_dir().ok_or("Cannot determine home directory")?;
179    Ok(home.join(".algocline").join("hub_registries.json"))
180}
181
182/// Load registries from disk.  Returns empty list if file is missing.
183fn load_registries() -> HubRegistries {
184    let path = match registries_path() {
185        Ok(p) => p,
186        Err(_) => return HubRegistries::default(),
187    };
188    if !path.exists() {
189        return HubRegistries::default();
190    }
191    std::fs::read_to_string(&path)
192        .ok()
193        .and_then(|c| serde_json::from_str(&c).ok())
194        .unwrap_or_default()
195}
196
197/// Register a source URL.  Deduplicates by normalized URL.
198///
199/// Uses atomic write (tempfile + rename) to avoid partial writes if
200/// the process is interrupted.  Read-modify-write is not locked across
201/// processes, but MCP servers are single-process so this is safe in
202/// practice.
203pub(crate) fn register_source(source: &str, origin: &str) {
204    let normalized = source.trim_end_matches('/').to_string();
205    if normalized.is_empty() {
206        return;
207    }
208    // Skip local paths — they can't host a remote index
209    if normalized.starts_with('/') || normalized.starts_with('.') {
210        return;
211    }
212
213    let path = match registries_path() {
214        Ok(p) => p,
215        Err(_) => return,
216    };
217    if let Some(parent) = path.parent() {
218        let _ = std::fs::create_dir_all(parent);
219    }
220
221    // Re-read from disk right before write to minimize TOCTOU window
222    let mut reg = load_registries();
223
224    // Already registered?
225    if reg
226        .registries
227        .iter()
228        .any(|e| e.source.trim_end_matches('/') == normalized)
229    {
230        return;
231    }
232
233    reg.registries.push(RegistryEntry {
234        source: normalized,
235        origin: origin.to_string(),
236        added_at: manifest::now_iso8601(),
237    });
238
239    // Atomic write: write to temp file, then rename
240    match serde_json::to_string_pretty(&reg) {
241        Ok(json) => {
242            let tmp_path = path.with_extension("json.tmp");
243            if let Err(e) = std::fs::write(&tmp_path, &json) {
244                tracing::warn!("failed to write hub registries tmp: {e}");
245                return;
246            }
247            if let Err(e) = std::fs::rename(&tmp_path, &path) {
248                tracing::warn!("failed to rename hub registries: {e}");
249                // Clean up tmp on failure
250                let _ = std::fs::remove_file(&tmp_path);
251            }
252        }
253        Err(e) => tracing::warn!("failed to serialize hub registries: {e}"),
254    }
255}
256
257// ─── Hub config ──────────────────────────────────────────────
258//
259// Optional `[hub]` section in `~/.algocline/config.toml`:
260//
261//   [hub]
262//   collection_url = "https://raw.githubusercontent.com/.../hub_index.json"
263//
264// When set, this is fetched as Tier 0 (the aggregated collection
265// index containing all known packages, including uninstalled ones).
266
267/// Read the `[hub].collection_url` from `~/.algocline/config.toml`.
268fn collection_url_from_config() -> Option<String> {
269    let home = dirs::home_dir()?;
270    let path = home.join(".algocline").join("config.toml");
271    let content = std::fs::read_to_string(&path).ok()?;
272    let doc: toml_edit::DocumentMut = content.parse().ok()?;
273    let url = doc
274        .get("hub")?
275        .get("collection_url")?
276        .as_str()?
277        .trim()
278        .to_string();
279    if url.is_empty() {
280        None
281    } else {
282        Some(url)
283    }
284}
285
286// ─── Index URL discovery ──────────────────────────────────────
287//
288// Derives remote index URLs from:
289//   0. Hub Collection URL (from config.toml) — aggregated index
290//   1. Hub registries (`hub_registries.json`) — primary source
291//   2. Unique `source` fields in the installed-packages manifest
292//   3. `AUTO_INSTALL_SOURCES` as fallback seeds (for first run)
293//
294// GitHub repos are transformed:
295//   https://github.com/{owner}/{repo}  →
296//   https://raw.githubusercontent.com/{owner}/{repo}/main/hub_index.json
297
298/// Convert a GitHub repo URL to a raw `hub_index.json` URL.
299/// Returns `None` for non-GitHub URLs (future: support other hosts).
300fn repo_to_index_url(repo_url: &str) -> Option<String> {
301    let trimmed = repo_url.trim_end_matches('/').trim_end_matches(".git");
302    if let Some(path) = trimmed.strip_prefix("https://github.com/") {
303        // path = "owner/repo"
304        let parts: Vec<&str> = path.splitn(3, '/').collect();
305        if parts.len() >= 2 {
306            return Some(format!(
307                "https://raw.githubusercontent.com/{}/{}/main/hub_index.json",
308                parts[0], parts[1]
309            ));
310        }
311    }
312    // Non-GitHub URL: assume it's already a direct index URL
313    if trimmed.ends_with(".json") {
314        Some(trimmed.to_string())
315    } else {
316        None
317    }
318}
319
320/// Collect unique index URLs from config + registries + manifest + bundled seeds.
321fn discover_index_urls() -> Vec<String> {
322    let mut index_urls: Vec<String> = Vec::new();
323
324    // 0. From config.toml [hub].collection_url (Tier 0 — aggregated collection)
325    if let Some(url) = collection_url_from_config() {
326        index_urls.push(url);
327    }
328
329    let mut repo_urls: HashSet<String> = HashSet::new();
330
331    // 1. From hub registries (primary)
332    let reg = load_registries();
333    for entry in &reg.registries {
334        let normalized = entry.source.trim_end_matches('/').to_string();
335        if !normalized.is_empty() {
336            repo_urls.insert(normalized);
337        }
338    }
339
340    // 2. From manifest (catch sources registered before hub_registries existed)
341    if let Ok(m) = manifest::load_manifest() {
342        for entry in m.packages.values() {
343            let normalized = entry.source.trim_end_matches('/').to_string();
344            if !normalized.is_empty() && !normalized.starts_with('/') {
345                repo_urls.insert(normalized);
346            }
347        }
348    }
349
350    // 3. Fallback: bundled sources (ensures at least these are checked)
351    for url in AUTO_INSTALL_SOURCES {
352        repo_urls.insert(url.to_string());
353    }
354
355    // 4. Transform repo URLs → index URLs, dedup against Tier 0
356    let existing: HashSet<String> = index_urls.iter().cloned().collect();
357    let mut derived: Vec<String> = repo_urls
358        .iter()
359        .filter_map(|url| repo_to_index_url(url))
360        .filter(|url| !existing.contains(url))
361        .collect();
362    derived.sort();
363    derived.dedup();
364    index_urls.extend(derived);
365
366    index_urls
367}
368
369// ─── Per-source cache ─────────────────────────────────────────
370//
371// Each remote index is cached separately at
372// `~/.algocline/hub_cache/{hash}.json` where hash is derived from
373// the index URL. This avoids mixing data from different registries
374// and allows per-source TTL validation.
375
376fn cache_dir() -> Result<PathBuf, String> {
377    let home = dirs::home_dir().ok_or("Cannot determine home directory")?;
378    Ok(home.join(".algocline").join("hub_cache"))
379}
380
381fn cache_key(url: &str) -> String {
382    // Simple hash: use the URL bytes to produce a stable hex string.
383    // Avoids pulling in a hash crate — good enough for cache file naming.
384    let mut h: u64 = 0xcbf2_9ce4_8422_2325; // FNV-1a offset basis
385    for b in url.as_bytes() {
386        h ^= *b as u64;
387        h = h.wrapping_mul(0x0100_0000_01b3); // FNV prime
388    }
389    format!("{h:016x}")
390}
391
392/// Load cached remote index for a specific URL if fresh (within TTL).
393fn load_cached(url: &str) -> Option<HubIndex> {
394    let dir = cache_dir().ok()?;
395    let path = dir.join(format!("{}.json", cache_key(url)));
396    if !path.exists() {
397        return None;
398    }
399    let metadata = std::fs::metadata(&path).ok()?;
400    let age = metadata.modified().ok()?.elapsed().ok()?;
401    if age.as_secs() > CACHE_TTL_SECS {
402        return None;
403    }
404    let content = std::fs::read_to_string(&path).ok()?;
405    serde_json::from_str(&content).ok()
406}
407
408/// Save remote index to per-source cache file.
409fn save_cached(url: &str, index: &HubIndex) {
410    let dir = match cache_dir() {
411        Ok(d) => d,
412        Err(e) => {
413            tracing::warn!("hub cache dir unavailable: {e}");
414            return;
415        }
416    };
417    if let Err(e) = std::fs::create_dir_all(&dir) {
418        tracing::warn!("failed to create hub cache dir: {e}");
419        return;
420    }
421    let path = dir.join(format!("{}.json", cache_key(url)));
422    match serde_json::to_string_pretty(index) {
423        Ok(json) => {
424            if let Err(e) = std::fs::write(&path, json) {
425                tracing::warn!("failed to write hub cache {}: {e}", path.display());
426            }
427        }
428        Err(e) => tracing::warn!("failed to serialize hub cache: {e}"),
429    }
430}
431
432// ─── Remote fetch ──────────────────────────────────────────────
433
434/// Fetch a single remote index by URL, using per-source cache.
435fn fetch_one(url: &str) -> Result<HubIndex, String> {
436    if let Some(cached) = load_cached(url) {
437        return Ok(cached);
438    }
439
440    let agent = ureq::Agent::new_with_config(
441        ureq::config::Config::builder()
442            .timeout_global(Some(HTTP_TIMEOUT))
443            .build(),
444    );
445    let body: String = agent
446        .get(url)
447        .call()
448        .map_err(|e| format!("Failed to fetch {url}: {e}"))?
449        .body_mut()
450        .read_to_string()
451        .map_err(|e| format!("Failed to read response from {url}: {e}"))?;
452
453    let index: HubIndex = serde_json::from_str(&body)
454        .map_err(|e| format!("Failed to parse index from {url}: {e}"))?;
455
456    save_cached(url, &index);
457    Ok(index)
458}
459
460/// Fetch all discovered remote indices and merge into one.
461/// Falls back gracefully: failed sources are skipped with warnings.
462fn fetch_remote_indices() -> (HubIndex, Vec<String>) {
463    let urls = discover_index_urls();
464    let mut all_packages: Vec<IndexEntry> = Vec::new();
465    let mut seen_names: HashSet<String> = HashSet::new();
466    let mut warnings: Vec<String> = Vec::new();
467
468    for url in &urls {
469        match fetch_one(url) {
470            Ok(index) => {
471                for entry in index.packages {
472                    if seen_names.insert(entry.name.clone()) {
473                        all_packages.push(entry);
474                    }
475                    // If duplicate name across sources, first wins
476                }
477            }
478            Err(e) => {
479                warnings.push(e);
480            }
481        }
482    }
483
484    if all_packages.is_empty() && !warnings.is_empty() {
485        warnings.insert(
486            0,
487            "all remote indices unavailable, showing local packages only".to_string(),
488        );
489    }
490
491    let merged = HubIndex {
492        schema_version: "hub_index/v0".into(),
493        updated_at: String::new(),
494        packages: all_packages,
495    };
496    (merged, warnings)
497}
498
499// ─── Local state ───────────────────────────────────────────────
500
501/// Build a set of locally installed package names from `installed.json`
502/// and the `~/.algocline/packages/` directory.
503fn installed_packages() -> HashMap<String, Option<String>> {
504    let mut map = HashMap::new();
505
506    // From manifest (has version info)
507    if let Ok(m) = manifest::load_manifest() {
508        for (name, entry) in &m.packages {
509            map.insert(name.clone(), entry.version.clone());
510        }
511    }
512
513    // Also scan packages/ dir in case manifest is stale
514    if let Some(home) = dirs::home_dir() {
515        let pkg_dir = home.join(".algocline").join("packages");
516        if let Ok(entries) = std::fs::read_dir(&pkg_dir) {
517            for entry in entries.flatten() {
518                if entry.path().is_dir() {
519                    if let Some(name) = entry.file_name().to_str() {
520                        map.entry(name.to_string()).or_insert(None);
521                    }
522                }
523            }
524        }
525    }
526
527    map
528}
529
530/// Count local cards per package from `~/.algocline/cards/{pkg}/`.
531fn local_card_counts() -> HashMap<String, usize> {
532    let mut map = HashMap::new();
533    let home = match dirs::home_dir() {
534        Some(h) => h,
535        None => return map,
536    };
537    let cards_dir = home.join(".algocline").join("cards");
538    let entries = match std::fs::read_dir(&cards_dir) {
539        Ok(e) => e,
540        Err(_) => return map,
541    };
542    for entry in entries.flatten() {
543        if !entry.path().is_dir() {
544            continue;
545        }
546        let pkg = match entry.file_name().to_str() {
547            Some(n) => n.to_string(),
548            None => continue,
549        };
550        let count = std::fs::read_dir(entry.path())
551            .map(|es| {
552                es.flatten()
553                    .filter(|e| e.path().extension().is_some_and(|ext| ext == "toml"))
554                    .count()
555            })
556            .unwrap_or(0);
557        if count > 0 {
558            map.insert(pkg, count);
559        }
560    }
561    map
562}
563
564/// Count eval results for a specific package by scanning `~/.algocline/evals/`.
565///
566/// Reads only `.meta.json` files (lightweight) to check the strategy field.
567/// Falls back to reading full eval JSON if meta is missing.
568fn count_evals_for_pkg(pkg: &str) -> usize {
569    let home = match dirs::home_dir() {
570        Some(h) => h,
571        None => return 0,
572    };
573    let evals_dir = home.join(".algocline").join("evals");
574    let entries = match std::fs::read_dir(&evals_dir) {
575        Ok(e) => e,
576        Err(_) => return 0,
577    };
578
579    // Collect all filenames first so ordering doesn't matter.
580    // We track stems that have a .meta.json to avoid reading the full eval JSON.
581    let mut meta_stems: HashSet<String> = HashSet::new();
582    let mut meta_matches: usize = 0;
583    let mut non_meta_paths: Vec<(PathBuf, String)> = Vec::new(); // (path, stem)
584
585    for entry in entries.flatten() {
586        let path = entry.path();
587        let name = match path.file_name().and_then(|n| n.to_str()) {
588            Some(n) => n.to_string(),
589            None => continue,
590        };
591
592        if name.ends_with(".meta.json") {
593            let stem = name.trim_end_matches(".meta.json").to_string();
594            meta_stems.insert(stem);
595            if let Ok(content) = std::fs::read_to_string(&path) {
596                if let Ok(val) = serde_json::from_str::<serde_json::Value>(&content) {
597                    if val.get("strategy").and_then(|s| s.as_str()) == Some(pkg) {
598                        meta_matches += 1;
599                    }
600                }
601            }
602            continue;
603        }
604
605        // Skip non-json or comparison files
606        if !name.ends_with(".json") || name.starts_with("compare_") {
607            continue;
608        }
609
610        let stem = path
611            .file_stem()
612            .and_then(|s| s.to_str())
613            .unwrap_or("")
614            .to_string();
615        non_meta_paths.push((path, stem));
616    }
617
618    // Only read full eval JSON for entries without a .meta.json
619    let fallback_matches = non_meta_paths
620        .iter()
621        .filter(|(_, stem)| !meta_stems.contains(stem))
622        .filter(|(path, _)| {
623            std::fs::read_to_string(path)
624                .ok()
625                .and_then(|c| serde_json::from_str::<serde_json::Value>(&c).ok())
626                .and_then(|v| v.get("strategy")?.as_str().map(|s| s == pkg))
627                .unwrap_or(false)
628        })
629        .count();
630
631    meta_matches + fallback_matches
632}
633
634// ─── Merge ─────────────────────────────────────────────────────
635
636/// Merge remote index with local install state.
637///
638/// When a package is installed locally and the remote index lacks a
639/// docstring (pre-v0.21 indices), the docstring is extracted from the
640/// local `init.lua` so that full-text search works immediately.
641fn merge(remote: &HubIndex) -> Vec<SearchResult> {
642    let installed = installed_packages();
643    let card_counts = local_card_counts();
644    let pkg_dir = dirs::home_dir().map(|h| h.join(".algocline").join("packages"));
645
646    let mut seen: HashSet<String> = HashSet::new();
647    let mut results: Vec<SearchResult> = Vec::new();
648
649    for entry in &remote.packages {
650        let is_installed = installed.contains_key(&entry.name);
651        let local_cards = card_counts.get(&entry.name).copied().unwrap_or(0);
652
653        // Supplement empty docstring from local init.lua when installed
654        let docstring = if entry.docstring.is_empty() && is_installed {
655            pkg_dir
656                .as_ref()
657                .map(|d| extract_docstring(&d.join(&entry.name).join("init.lua")))
658                .unwrap_or_default()
659        } else {
660            entry.docstring.clone()
661        };
662
663        seen.insert(entry.name.clone());
664        results.push(SearchResult {
665            name: entry.name.clone(),
666            version: entry.version.clone(),
667            description: entry.description.clone(),
668            category: entry.category.clone(),
669            source: entry.source.clone(),
670            installed: is_installed,
671            card_count: if is_installed && local_cards > entry.card_count {
672                local_cards
673            } else {
674                entry.card_count
675            },
676            best_card: entry.best_card.clone(),
677            docstring,
678        });
679    }
680
681    // Add local-only packages (not in remote index).
682    for (name, version) in &installed {
683        if seen.contains(name) {
684            continue;
685        }
686        let docstring = pkg_dir
687            .as_ref()
688            .map(|d| extract_docstring(&d.join(name).join("init.lua")))
689            .unwrap_or_default();
690        results.push(SearchResult {
691            name: name.clone(),
692            version: version.clone().unwrap_or_default(),
693            description: String::new(),
694            category: String::new(),
695            source: String::new(),
696            installed: true,
697            card_count: card_counts.get(name).copied().unwrap_or(0),
698            best_card: None,
699            docstring,
700        });
701    }
702
703    results
704}
705
706// ─── Search (filtering) ───────────────────────────────────────
707
708fn matches_query(result: &SearchResult, query: &str) -> bool {
709    let q = query.to_lowercase();
710    result.name.to_lowercase().contains(&q)
711        || result.description.to_lowercase().contains(&q)
712        || result.category.to_lowercase().contains(&q)
713        || result.docstring.to_lowercase().contains(&q)
714}
715
716// ─── Index generation (reindex) ───────────────────────────────
717
718/// Extract leading `---` docstring lines from an `init.lua` file.
719///
720/// Collects consecutive lines starting with `---` (Lua doc-comment)
721/// from the beginning of the file.  Stops at the first non-doc line.
722/// Returns a single string with lines joined by newline, stripped of
723/// the `---` prefix.  Used for full-text search in hub_search.
724fn extract_docstring(path: &std::path::Path) -> String {
725    let content = match std::fs::read_to_string(path) {
726        Ok(c) => c,
727        Err(_) => return String::new(),
728    };
729    let mut lines = Vec::new();
730    for line in content.lines() {
731        let trimmed = line.trim_start();
732        if let Some(rest) = trimmed.strip_prefix("---") {
733            lines.push(rest.trim().to_string());
734        } else if trimmed.is_empty() {
735            // Allow blank lines within the docstring block
736            continue;
737        } else {
738            break;
739        }
740    }
741    lines.join("\n")
742}
743
744/// Parse `M.meta = { ... }` from an `init.lua` file without Lua VM.
745///
746/// Extracts (name, version, description, category) from the first
747/// `M.meta = { ... }` block found anywhere in the file.
748///
749/// Supports string concatenation: `description = "foo " .. "bar"` is
750/// collected as `"foo bar"`.
751///
752/// **Limitation**: Only supports flat key-value pairs inside `M.meta`.
753/// Nested tables (e.g. `tags = { ... }`) are skipped via brace-depth
754/// tracking. `M.meta` fields are expected to be simple (possibly
755/// concatenated) string literals.
756fn parse_meta_from_init_lua(path: &std::path::Path) -> Option<(String, String, String, String)> {
757    let content = std::fs::read_to_string(path).ok()?;
758    let head = content.as_str();
759
760    // Find M.meta = { ... } block (with brace-depth tracking).
761    // Skip occurrences inside Lua line comments (`-- ...`) so that
762    // docstrings mentioning "M.meta" do not hijack the search.
763    let mut search_from = 0;
764    let meta_start = loop {
765        let rel = head[search_from..].find("M.meta")?;
766        let pos = search_from + rel;
767        let line_start = head[..pos].rfind('\n').map(|i| i + 1).unwrap_or(0);
768        if !head[line_start..pos].contains("--") {
769            break pos;
770        }
771        search_from = pos + "M.meta".len();
772    };
773    let brace_start = head[meta_start..].find('{')? + meta_start;
774
775    // Track brace depth to handle nested tables correctly
776    let mut depth = 0;
777    let mut brace_end = None;
778    for (i, ch) in head[brace_start..].char_indices() {
779        match ch {
780            '{' => depth += 1,
781            '}' => {
782                depth -= 1;
783                if depth == 0 {
784                    brace_end = Some(brace_start + i);
785                    break;
786                }
787            }
788            _ => {}
789        }
790    }
791    let brace_end = brace_end?;
792    let block = &head[brace_start + 1..brace_end];
793
794    let extract = |field: &str| -> String {
795        // Match: field = "value" [.. "value" ...] with word-boundary check.
796        // Walk through all occurrences of `field`, skipping matches inside
797        // longer identifiers (e.g. "short_description"). On the first valid
798        // occurrence, collect one or more `"..."` string literals joined by
799        // `..` concatenation operators.
800        let mut search_from = 0;
801        while let Some(rel) = block[search_from..].find(field) {
802            let pos = search_from + rel;
803            let word_boundary = pos == 0 || {
804                let prev = block.as_bytes()[pos - 1];
805                !(prev.is_ascii_alphanumeric() || prev == b'_')
806            };
807            if word_boundary {
808                let after = &block[pos + field.len()..];
809                let mut collected = String::new();
810                let mut cursor = 0usize;
811                let mut found_any = false;
812                loop {
813                    let rest = &after[cursor..];
814                    let Some(q_start_rel) = rest.find('"') else {
815                        break;
816                    };
817                    if found_any {
818                        // Between the prior closing quote and this opening
819                        // quote, only whitespace and a single `..` operator
820                        // are allowed. Anything else (comma, another field,
821                        // etc.) ends the value.
822                        let between = &rest[..q_start_rel];
823                        if between.trim() != ".." {
824                            break;
825                        }
826                    }
827                    let lit_start = cursor + q_start_rel + 1;
828                    let Some(q_end_rel) = after[lit_start..].find('"') else {
829                        break;
830                    };
831                    collected.push_str(&after[lit_start..lit_start + q_end_rel]);
832                    cursor = lit_start + q_end_rel + 1;
833                    found_any = true;
834                }
835                if found_any {
836                    return collected;
837                }
838            }
839            search_from = pos + field.len();
840        }
841        String::new()
842    };
843
844    let name = extract("name");
845    if name.is_empty() {
846        return None;
847    }
848    Some((
849        name,
850        extract("version"),
851        extract("description"),
852        extract("category"),
853    ))
854}
855
856/// Build a hub index by scanning a packages directory.
857///
858/// When `source_dir` is provided, scans that directory directly
859/// (for generating an index from a repo checkout).  Metadata comes
860/// only from `init.lua` — no manifest lookup, no card counts.
861///
862/// When `source_dir` is `None`, scans `~/.algocline/packages/` and
863/// enriches entries with manifest source and local card counts.
864fn build_index(source_dir: Option<&std::path::Path>) -> HubIndex {
865    let empty = || HubIndex {
866        schema_version: "hub_index/v0".into(),
867        updated_at: super::manifest::now_iso8601(),
868        packages: Vec::new(),
869    };
870
871    let pkg_dir = match source_dir {
872        Some(d) => d.to_path_buf(),
873        None => {
874            let home = match dirs::home_dir() {
875                Some(h) => h,
876                None => return empty(),
877            };
878            home.join(".algocline").join("packages")
879        }
880    };
881
882    let use_local_state = source_dir.is_none();
883    let card_counts = if use_local_state {
884        local_card_counts()
885    } else {
886        HashMap::new()
887    };
888    let manifest = if use_local_state {
889        manifest::load_manifest().unwrap_or_default()
890    } else {
891        manifest::Manifest::default()
892    };
893
894    let mut entries = Vec::new();
895
896    let dir_entries = match std::fs::read_dir(&pkg_dir) {
897        Ok(e) => e,
898        Err(_) => return empty(),
899    };
900
901    for entry in dir_entries.flatten() {
902        if !entry.path().is_dir() {
903            continue;
904        }
905        let dir_name = match entry.file_name().to_str() {
906            Some(n) if !n.starts_with('.') && !n.starts_with('_') => n.to_string(),
907            _ => continue,
908        };
909
910        let init_lua = entry.path().join("init.lua");
911        if !init_lua.exists() {
912            continue;
913        }
914
915        let (name, version, description, category) = parse_meta_from_init_lua(&init_lua)
916            .unwrap_or_else(|| {
917                (
918                    dir_name.clone(),
919                    String::new(),
920                    String::new(),
921                    String::new(),
922                )
923            });
924
925        let docstring = extract_docstring(&init_lua);
926
927        // Use manifest source only for local-state mode
928        let source = manifest
929            .packages
930            .get(&dir_name)
931            .map(|e| e.source.clone())
932            .unwrap_or_default();
933
934        entries.push(IndexEntry {
935            name,
936            version,
937            description,
938            category,
939            source,
940            card_count: card_counts.get(&dir_name).copied().unwrap_or(0),
941            best_card: None,
942            docstring,
943        });
944    }
945
946    entries.sort_by(|a, b| a.name.cmp(&b.name));
947
948    HubIndex {
949        schema_version: "hub_index/v0".into(),
950        updated_at: super::manifest::now_iso8601(),
951        packages: entries,
952    }
953}
954
955// ─── Public API ────────────────────────────────────────────────
956
957impl AppService {
958    /// Generate a hub index from a packages directory.
959    ///
960    /// When `source_dir` is provided, scans that directory (e.g. a
961    /// repo checkout) — pure metadata extraction, no manifest or card
962    /// data mixed in.  When omitted, scans `~/.algocline/packages/`.
963    ///
964    /// Writes the index to `output_path` (for CI / publishing).
965    /// Does NOT touch the remote search cache.
966    pub fn hub_reindex(
967        &self,
968        output_path: Option<&str>,
969        source_dir: Option<&str>,
970    ) -> Result<String, String> {
971        let src = source_dir.map(std::path::Path::new);
972        if let Some(d) = src {
973            if !d.is_dir() {
974                return Err(format!("source_dir '{}' is not a directory", d.display()));
975            }
976        }
977        let index = build_index(src);
978
979        let written_path = if let Some(path) = output_path {
980            let json = serde_json::to_string_pretty(&index)
981                .map_err(|e| format!("Failed to serialize index: {e}"))?;
982            std::fs::write(path, &json)
983                .map_err(|e| format!("Failed to write index to {path}: {e}"))?;
984            Some(path.to_string())
985        } else {
986            None
987        };
988
989        let response = serde_json::json!({
990            "package_count": index.packages.len(),
991            "updated_at": index.updated_at,
992            "output_path": written_path,
993            "source_dir": source_dir,
994        });
995        Ok(response.to_string())
996    }
997
998    /// Show detailed information for a single package.
999    ///
1000    /// Aggregates package metadata (from index or local `init.lua`),
1001    /// all Cards, aliases, and eval stats into one response.
1002    pub fn hub_info(&self, pkg: &str) -> Result<String, String> {
1003        use algocline_engine::card;
1004
1005        // Guard against path traversal
1006        if pkg.contains("..") || pkg.contains('/') || pkg.contains('\\') {
1007            return Err(format!("Invalid package name: '{pkg}'"));
1008        }
1009
1010        // Package metadata: try remote index first, fall back to local
1011        let installed = installed_packages();
1012        let is_installed = installed.contains_key(pkg);
1013
1014        let (version, description, category, source) = {
1015            // Try to get from remote index
1016            let (remote, _) = fetch_remote_indices();
1017            if let Some(entry) = remote.packages.iter().find(|e| e.name == pkg) {
1018                (
1019                    entry.version.clone(),
1020                    entry.description.clone(),
1021                    entry.category.clone(),
1022                    entry.source.clone(),
1023                )
1024            } else if is_installed {
1025                // Fall back to local init.lua parse
1026                let home = dirs::home_dir().ok_or("Cannot determine home directory")?;
1027                let init_lua = home
1028                    .join(".algocline")
1029                    .join("packages")
1030                    .join(pkg)
1031                    .join("init.lua");
1032                let meta = parse_meta_from_init_lua(&init_lua);
1033                let manifest_source = manifest::load_manifest()
1034                    .ok()
1035                    .and_then(|m| m.packages.get(pkg).map(|e| e.source.clone()))
1036                    .unwrap_or_default();
1037                match meta {
1038                    Some((_, v, d, c)) => (v, d, c, manifest_source),
1039                    None => (
1040                        installed.get(pkg).cloned().flatten().unwrap_or_default(),
1041                        String::new(),
1042                        String::new(),
1043                        manifest_source,
1044                    ),
1045                }
1046            } else {
1047                return Err(format!(
1048                    "Package '{pkg}' not found in remote indices or locally installed packages"
1049                ));
1050            }
1051        };
1052
1053        // Cards for this package (single call, reused for stats)
1054        let card_rows = card::list(Some(pkg)).unwrap_or_default();
1055        let cards_json = card::summaries_to_json(&card_rows);
1056
1057        // Aliases for this package
1058        let aliases_json = match card::alias_list(Some(pkg)) {
1059            Ok(rows) => card::aliases_to_json(&rows),
1060            Err(_) => serde_json::json!([]),
1061        };
1062
1063        // Stats: card count, best pass_rate, eval count
1064        let card_count = card_rows.len();
1065        let best_pass_rate = card_rows
1066            .iter()
1067            .filter_map(|c| c.pass_rate)
1068            .fold(f64::NEG_INFINITY, f64::max);
1069        let best_pass_rate = if best_pass_rate.is_finite() {
1070            Some(best_pass_rate)
1071        } else {
1072            None
1073        };
1074
1075        // Eval count from evals directory
1076        let eval_count = count_evals_for_pkg(pkg);
1077
1078        let response = serde_json::json!({
1079            "pkg": {
1080                "name": pkg,
1081                "version": version,
1082                "description": description,
1083                "category": category,
1084                "source": source,
1085                "installed": is_installed,
1086            },
1087            "cards": cards_json,
1088            "aliases": aliases_json,
1089            "stats": {
1090                "card_count": card_count,
1091                "eval_count": eval_count,
1092                "best_pass_rate": best_pass_rate,
1093            },
1094        });
1095        Ok(response.to_string())
1096    }
1097
1098    /// Search packages across remote indices + local state.
1099    ///
1100    /// Index URLs are discovered from hub registries, manifest sources,
1101    /// and `AUTO_INSTALL_SOURCES`. Each source is cached independently.
1102    pub fn hub_search(
1103        &self,
1104        query: Option<&str>,
1105        category: Option<&str>,
1106        installed_only: Option<bool>,
1107        limit: Option<usize>,
1108    ) -> Result<String, String> {
1109        let (remote, warnings) = fetch_remote_indices();
1110        let mut results = merge(&remote);
1111
1112        // Filter by query
1113        if let Some(q) = query {
1114            if !q.is_empty() {
1115                results.retain(|r| matches_query(r, q));
1116            }
1117        }
1118
1119        // Filter by category
1120        if let Some(cat) = category {
1121            let cat_lower = cat.to_lowercase();
1122            results.retain(|r| r.category.to_lowercase() == cat_lower);
1123        }
1124
1125        // Filter by installed state
1126        if let Some(true) = installed_only {
1127            results.retain(|r| r.installed);
1128        }
1129
1130        // Sort: installed first, then by name
1131        results.sort_by(|a, b| {
1132            b.installed
1133                .cmp(&a.installed)
1134                .then_with(|| a.name.cmp(&b.name))
1135        });
1136
1137        // Limit
1138        let total = results.len();
1139        let limit = limit.unwrap_or(50);
1140        results.truncate(limit);
1141
1142        // Collect discovered sources for transparency
1143        let sources = discover_index_urls();
1144
1145        let mut json = serde_json::json!({
1146            "results": results,
1147            "total": total,
1148            "sources": sources,
1149        });
1150        if !warnings.is_empty() {
1151            json["warnings"] = serde_json::json!(warnings);
1152        }
1153        Ok(json.to_string())
1154    }
1155}
1156
1157#[cfg(test)]
1158mod tests {
1159    use super::*;
1160
1161    #[test]
1162    fn repo_to_index_url_github() {
1163        assert_eq!(
1164            repo_to_index_url("https://github.com/ynishi/algocline-bundled-packages"),
1165            Some(
1166                "https://raw.githubusercontent.com/ynishi/algocline-bundled-packages/main/hub_index.json"
1167                    .to_string()
1168            )
1169        );
1170    }
1171
1172    #[test]
1173    fn repo_to_index_url_github_trailing_slash() {
1174        assert_eq!(
1175            repo_to_index_url("https://github.com/user/repo/"),
1176            Some("https://raw.githubusercontent.com/user/repo/main/hub_index.json".to_string())
1177        );
1178    }
1179
1180    #[test]
1181    fn repo_to_index_url_github_dot_git() {
1182        assert_eq!(
1183            repo_to_index_url("https://github.com/user/repo.git"),
1184            Some("https://raw.githubusercontent.com/user/repo/main/hub_index.json".to_string())
1185        );
1186    }
1187
1188    #[test]
1189    fn repo_to_index_url_direct_json() {
1190        assert_eq!(
1191            repo_to_index_url("https://example.com/my_index.json"),
1192            Some("https://example.com/my_index.json".to_string())
1193        );
1194    }
1195
1196    #[test]
1197    fn repo_to_index_url_unknown_host_no_json() {
1198        assert_eq!(repo_to_index_url("https://example.com/some-repo"), None);
1199    }
1200
1201    #[test]
1202    fn repo_to_index_url_local_path() {
1203        assert_eq!(repo_to_index_url("/home/user/my-pkg"), None);
1204    }
1205
1206    #[test]
1207    fn cache_key_stable() {
1208        let k1 = cache_key("https://example.com/index.json");
1209        let k2 = cache_key("https://example.com/index.json");
1210        assert_eq!(k1, k2);
1211        assert_eq!(k1.len(), 16); // 16 hex chars
1212    }
1213
1214    #[test]
1215    fn cache_key_different_urls() {
1216        let k1 = cache_key("https://a.com/index.json");
1217        let k2 = cache_key("https://b.com/index.json");
1218        assert_ne!(k1, k2);
1219    }
1220
1221    #[test]
1222    fn parse_meta_flat() {
1223        let tmp = tempfile::tempdir().unwrap();
1224        let path = tmp.path().join("init.lua");
1225        std::fs::write(
1226            &path,
1227            r#"
1228local M = {}
1229M.meta = {
1230    name = "my_pkg",
1231    version = "1.0.0",
1232    description = "A test package",
1233    category = "reasoning",
1234}
1235return M
1236"#,
1237        )
1238        .unwrap();
1239
1240        let result = parse_meta_from_init_lua(&path).unwrap();
1241        assert_eq!(result.0, "my_pkg");
1242        assert_eq!(result.1, "1.0.0");
1243        assert_eq!(result.2, "A test package");
1244        assert_eq!(result.3, "reasoning");
1245    }
1246
1247    #[test]
1248    fn parse_meta_nested_table() {
1249        let tmp = tempfile::tempdir().unwrap();
1250        let path = tmp.path().join("init.lua");
1251        std::fs::write(
1252            &path,
1253            r#"
1254local M = {}
1255M.meta = {
1256    name = "nested_pkg",
1257    tags = { "a", "b" },
1258    description = "After nested",
1259}
1260return M
1261"#,
1262        )
1263        .unwrap();
1264
1265        let result = parse_meta_from_init_lua(&path).unwrap();
1266        assert_eq!(result.0, "nested_pkg");
1267        assert_eq!(result.2, "After nested");
1268    }
1269
1270    /// End-to-end sanity check against a real bundled-packages checkout.
1271    /// Set `BUNDLED_PACKAGES_DIR` to the repo root and run with
1272    /// `cargo test -- --ignored parse_meta_real_bundled`.
1273    #[test]
1274    #[ignore]
1275    fn parse_meta_real_bundled_packages() {
1276        let Ok(root) = std::env::var("BUNDLED_PACKAGES_DIR") else {
1277            panic!("set BUNDLED_PACKAGES_DIR=/path/to/algocline-bundled-packages");
1278        };
1279        let root = std::path::Path::new(&root);
1280        let mut total = 0usize;
1281        let mut failed_parse: Vec<String> = Vec::new();
1282        let mut empty_desc: Vec<String> = Vec::new();
1283        for entry in std::fs::read_dir(root).unwrap().flatten() {
1284            if !entry.path().is_dir() {
1285                continue;
1286            }
1287            let name = entry.file_name().to_string_lossy().to_string();
1288            if name.starts_with('.') || name.starts_with('_') {
1289                continue;
1290            }
1291            let init_lua = entry.path().join("init.lua");
1292            if !init_lua.exists() {
1293                continue;
1294            }
1295            total += 1;
1296            match parse_meta_from_init_lua(&init_lua) {
1297                Some((_n, _v, desc, _c)) => {
1298                    if desc.is_empty() {
1299                        empty_desc.push(name);
1300                    }
1301                }
1302                None => failed_parse.push(name),
1303            }
1304        }
1305        assert!(total >= 100, "expected ≥100 pkgs, got {total}");
1306        assert!(
1307            failed_parse.is_empty(),
1308            "parse_meta returned None for {} pkgs: {:?}",
1309            failed_parse.len(),
1310            failed_parse
1311        );
1312        assert!(
1313            empty_desc.is_empty(),
1314            "empty description for {} pkgs: {:?}",
1315            empty_desc.len(),
1316            empty_desc
1317        );
1318    }
1319
1320    #[test]
1321    fn parse_meta_concat_string_literals() {
1322        // description = "foo " .. "bar " .. "baz" should produce "foo bar baz"
1323        let tmp = tempfile::tempdir().unwrap();
1324        let path = tmp.path().join("init.lua");
1325        std::fs::write(
1326            &path,
1327            r#"
1328local M = {}
1329M.meta = {
1330    name = "concat_pkg",
1331    version = "0.1.0",
1332    description = "Adaptive Branching MCTS — Thompson Sampling with dynamic "
1333        .. "wider/deeper decisions. GEN node mechanism for principled branching. "
1334        .. "Consistently outperforms standard MCTS and repeated sampling.",
1335    category = "reasoning",
1336}
1337return M
1338"#,
1339        )
1340        .unwrap();
1341
1342        let result = parse_meta_from_init_lua(&path).unwrap();
1343        assert_eq!(result.0, "concat_pkg");
1344        assert_eq!(result.1, "0.1.0");
1345        assert_eq!(
1346            result.2,
1347            "Adaptive Branching MCTS — Thompson Sampling with dynamic \
1348             wider/deeper decisions. GEN node mechanism for principled branching. \
1349             Consistently outperforms standard MCTS and repeated sampling."
1350        );
1351        assert_eq!(result.3, "reasoning");
1352    }
1353
1354    #[test]
1355    fn parse_meta_large_leading_docstring() {
1356        // M.meta located beyond 2KB (long leading --- docstring) must still parse.
1357        let tmp = tempfile::tempdir().unwrap();
1358        let path = tmp.path().join("init.lua");
1359        let mut content = String::new();
1360        // Generate ~4KB of leading comments
1361        for i in 0..120 {
1362            content.push_str(&format!(
1363                "--- line {i}: this is a long documentation comment to push M.meta beyond the old 2KB scan window\n"
1364            ));
1365        }
1366        content.push_str(
1367            r#"
1368local M = {}
1369M.meta = {
1370    name = "late_meta_pkg",
1371    version = "0.2.0",
1372    description = "Located past 2KB",
1373    category = "test",
1374}
1375return M
1376"#,
1377        );
1378        std::fs::write(&path, &content).unwrap();
1379        assert!(content.len() > 2048, "fixture should exceed 2KB");
1380
1381        let result = parse_meta_from_init_lua(&path).unwrap();
1382        assert_eq!(result.0, "late_meta_pkg");
1383        assert_eq!(result.1, "0.2.0");
1384        assert_eq!(result.2, "Located past 2KB");
1385        assert_eq!(result.3, "test");
1386    }
1387
1388    #[test]
1389    fn parse_meta_word_boundary() {
1390        let tmp = tempfile::tempdir().unwrap();
1391        let path = tmp.path().join("init.lua");
1392        std::fs::write(
1393            &path,
1394            r#"
1395local M = {}
1396M.meta = {
1397    name = "wb_pkg",
1398    short_description = "should not match",
1399    description = "correct one",
1400}
1401return M
1402"#,
1403        )
1404        .unwrap();
1405
1406        let result = parse_meta_from_init_lua(&path).unwrap();
1407        assert_eq!(result.0, "wb_pkg");
1408        assert_eq!(result.2, "correct one");
1409    }
1410
1411    #[test]
1412    fn merge_dedup_uses_hashset() {
1413        // Verify that merge correctly handles local-only packages
1414        // without O(n*m) behavior (structural test).
1415        let remote = HubIndex {
1416            schema_version: "hub_index/v0".into(),
1417            updated_at: String::new(),
1418            packages: vec![IndexEntry {
1419                name: "remote_only".into(),
1420                version: "1.0".into(),
1421                description: "from remote".into(),
1422                category: "test".into(),
1423                source: String::new(),
1424                card_count: 0,
1425                best_card: None,
1426                docstring: String::new(),
1427            }],
1428        };
1429
1430        let results = merge(&remote);
1431        // Should include remote_only + any locally installed packages
1432        assert!(results.iter().any(|r| r.name == "remote_only"));
1433    }
1434
1435    #[test]
1436    fn extract_docstring_collects_leading_comments() {
1437        let tmp = tempfile::tempdir().unwrap();
1438        let path = tmp.path().join("init.lua");
1439        std::fs::write(
1440            &path,
1441            r#"--- cascade — Multi-level difficulty routing with confidence gating
1442--- Based on: "FrugalGPT" (Chen et al., 2023)
1443--- Uses Thompson Sampling for budget allocation.
1444
1445local M = {}
1446M.meta = { name = "cascade" }
1447return M
1448"#,
1449        )
1450        .unwrap();
1451
1452        let doc = extract_docstring(&path);
1453        assert!(doc.contains("FrugalGPT"), "should contain paper ref");
1454        assert!(
1455            doc.contains("Thompson Sampling"),
1456            "should contain technique"
1457        );
1458        assert!(!doc.contains("local M"), "should not contain code");
1459    }
1460
1461    #[test]
1462    fn extract_docstring_empty_when_no_comments() {
1463        let tmp = tempfile::tempdir().unwrap();
1464        let path = tmp.path().join("init.lua");
1465        std::fs::write(&path, "local M = {}\nreturn M\n").unwrap();
1466
1467        let doc = extract_docstring(&path);
1468        assert!(doc.is_empty());
1469    }
1470
1471    #[test]
1472    fn matches_query_searches_docstring() {
1473        let result = SearchResult {
1474            name: "cascade".into(),
1475            version: "0.1.0".into(),
1476            description: "Multi-level routing".into(),
1477            category: "meta".into(),
1478            source: String::new(),
1479            installed: true,
1480            card_count: 0,
1481            best_card: None,
1482            docstring: "Based on FrugalGPT. Uses Thompson Sampling.".into(),
1483        };
1484
1485        assert!(matches_query(&result, "thompson"), "docstring match");
1486        assert!(matches_query(&result, "FrugalGPT"), "docstring match case");
1487        assert!(matches_query(&result, "routing"), "description match");
1488        assert!(!matches_query(&result, "bayesian"), "no match");
1489    }
1490}