Skip to main content

algocline_app/service/
hub.rs

1//! Hub — Remote Index search with local merge.
2//!
3//! Discovers index URLs from three sources (in priority order):
4//!   1. Hub registries (`~/.algocline/hub_registries.json`) — auto-populated
5//!      by `pkg_install` and `card_install`
6//!   2. Installed-packages manifest (`~/.algocline/installed.json`) — fallback
7//!      for sources registered before registries existed
8//!   3. `AUTO_INSTALL_SOURCES` — compiled-in seeds for first-run
9//!
10//! Fetches each remote index, merges with locally installed packages
11//! and cards, and returns search results with `installed: true/false`.
12//!
13//! Remote indices are cached per-source at
14//! `~/.algocline/hub_cache/{hash}.json` with a TTL of 1 hour.
15
16use std::collections::{HashMap, HashSet};
17use std::path::PathBuf;
18
19use serde::{Deserialize, Serialize};
20
21use super::manifest;
22use super::resolve::AUTO_INSTALL_SOURCES;
23use super::AppService;
24
25// ─── Constants ─────────────────────────────────────────────────
26
27/// Cache TTL in seconds (1 hour).
28const CACHE_TTL_SECS: u64 = 3600;
29
30/// HTTP request timeout (30 seconds).
31const HTTP_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(30);
32
33// ─── Index schema ──────────────────────────────────────────────
34
35/// Remote index — same shape as the local index so merge is trivial.
36#[derive(Debug, Clone, Serialize, Deserialize)]
37pub(crate) struct HubIndex {
38    pub schema_version: String,
39    #[serde(default)]
40    pub updated_at: String,
41    #[serde(default)]
42    pub packages: Vec<IndexEntry>,
43}
44
45/// One package in the index.
46#[derive(Debug, Clone, Serialize, Deserialize)]
47pub(crate) struct IndexEntry {
48    pub name: String,
49    #[serde(default)]
50    pub version: String,
51    #[serde(default)]
52    pub description: String,
53    #[serde(default)]
54    pub category: String,
55    #[serde(default)]
56    pub source: String,
57    #[serde(default)]
58    pub card_count: usize,
59    #[serde(default)]
60    pub best_card: Option<BestCard>,
61}
62
63/// Best card summary within a package.
64#[derive(Debug, Clone, Serialize, Deserialize)]
65pub(crate) struct BestCard {
66    pub card_id: String,
67    #[serde(default)]
68    pub model: String,
69    #[serde(default)]
70    pub pass_rate: f64,
71    #[serde(default)]
72    pub scenario: String,
73}
74
75/// Search result — index entry enriched with local install state.
76#[derive(Debug, Clone, Serialize)]
77struct SearchResult {
78    name: String,
79    version: String,
80    description: String,
81    category: String,
82    source: String,
83    installed: bool,
84    card_count: usize,
85    best_card: Option<BestCard>,
86}
87
88// ─── Hub registries ───────────────────────────────────────────
89//
90// Persistent file (`~/.algocline/hub_registries.json`) that records
91// source URLs from `pkg_install` and `card_install`.  This is the
92// primary source for Hub index URL discovery — the manifest and
93// `AUTO_INSTALL_SOURCES` serve as fallback seeds.
94
95/// One entry in `hub_registries.json`.
96#[derive(Debug, Clone, Serialize, Deserialize)]
97pub(crate) struct RegistryEntry {
98    /// Original source URL (Git repo or local path).
99    pub source: String,
100    /// How it was registered: "pkg_install" or "card_install".
101    pub origin: String,
102    /// ISO 8601 timestamp of when the entry was added.
103    pub added_at: String,
104}
105
106/// Top-level registries file.
107#[derive(Debug, Clone, Serialize, Deserialize, Default)]
108pub(crate) struct HubRegistries {
109    pub registries: Vec<RegistryEntry>,
110}
111
112fn registries_path() -> Result<PathBuf, String> {
113    let home = dirs::home_dir().ok_or("Cannot determine home directory")?;
114    Ok(home.join(".algocline").join("hub_registries.json"))
115}
116
117/// Load registries from disk.  Returns empty list if file is missing.
118fn load_registries() -> HubRegistries {
119    let path = match registries_path() {
120        Ok(p) => p,
121        Err(_) => return HubRegistries::default(),
122    };
123    if !path.exists() {
124        return HubRegistries::default();
125    }
126    std::fs::read_to_string(&path)
127        .ok()
128        .and_then(|c| serde_json::from_str(&c).ok())
129        .unwrap_or_default()
130}
131
132/// Register a source URL.  Deduplicates by normalized URL.
133///
134/// Uses atomic write (tempfile + rename) to avoid partial writes if
135/// the process is interrupted.  Read-modify-write is not locked across
136/// processes, but MCP servers are single-process so this is safe in
137/// practice.
138pub(crate) fn register_source(source: &str, origin: &str) {
139    let normalized = source.trim_end_matches('/').to_string();
140    if normalized.is_empty() {
141        return;
142    }
143    // Skip local paths — they can't host a remote index
144    if normalized.starts_with('/') || normalized.starts_with('.') {
145        return;
146    }
147
148    let path = match registries_path() {
149        Ok(p) => p,
150        Err(_) => return,
151    };
152    if let Some(parent) = path.parent() {
153        let _ = std::fs::create_dir_all(parent);
154    }
155
156    // Re-read from disk right before write to minimize TOCTOU window
157    let mut reg = load_registries();
158
159    // Already registered?
160    if reg
161        .registries
162        .iter()
163        .any(|e| e.source.trim_end_matches('/') == normalized)
164    {
165        return;
166    }
167
168    reg.registries.push(RegistryEntry {
169        source: normalized,
170        origin: origin.to_string(),
171        added_at: manifest::now_iso8601(),
172    });
173
174    // Atomic write: write to temp file, then rename
175    match serde_json::to_string_pretty(&reg) {
176        Ok(json) => {
177            let tmp_path = path.with_extension("json.tmp");
178            if let Err(e) = std::fs::write(&tmp_path, &json) {
179                tracing::warn!("failed to write hub registries tmp: {e}");
180                return;
181            }
182            if let Err(e) = std::fs::rename(&tmp_path, &path) {
183                tracing::warn!("failed to rename hub registries: {e}");
184                // Clean up tmp on failure
185                let _ = std::fs::remove_file(&tmp_path);
186            }
187        }
188        Err(e) => tracing::warn!("failed to serialize hub registries: {e}"),
189    }
190}
191
192// ─── Index URL discovery ──────────────────────────────────────
193//
194// Derives remote index URLs from:
195//   1. Hub registries (`hub_registries.json`) — primary source
196//   2. Unique `source` fields in the installed-packages manifest
197//   3. `AUTO_INSTALL_SOURCES` as fallback seeds (for first run)
198//
199// GitHub repos are transformed:
200//   https://github.com/{owner}/{repo}  →
201//   https://raw.githubusercontent.com/{owner}/{repo}/main/hub_index.json
202
203/// Convert a GitHub repo URL to a raw `hub_index.json` URL.
204/// Returns `None` for non-GitHub URLs (future: support other hosts).
205fn repo_to_index_url(repo_url: &str) -> Option<String> {
206    let trimmed = repo_url.trim_end_matches('/').trim_end_matches(".git");
207    if let Some(path) = trimmed.strip_prefix("https://github.com/") {
208        // path = "owner/repo"
209        let parts: Vec<&str> = path.splitn(3, '/').collect();
210        if parts.len() >= 2 {
211            return Some(format!(
212                "https://raw.githubusercontent.com/{}/{}/main/hub_index.json",
213                parts[0], parts[1]
214            ));
215        }
216    }
217    // Non-GitHub URL: assume it's already a direct index URL
218    if trimmed.ends_with(".json") {
219        Some(trimmed.to_string())
220    } else {
221        None
222    }
223}
224
225/// Collect unique index URLs from registries + manifest + bundled seeds.
226fn discover_index_urls() -> Vec<String> {
227    let mut repo_urls: HashSet<String> = HashSet::new();
228
229    // 1. From hub registries (primary)
230    let reg = load_registries();
231    for entry in &reg.registries {
232        let normalized = entry.source.trim_end_matches('/').to_string();
233        if !normalized.is_empty() {
234            repo_urls.insert(normalized);
235        }
236    }
237
238    // 2. From manifest (catch sources registered before hub_registries existed)
239    if let Ok(m) = manifest::load_manifest() {
240        for entry in m.packages.values() {
241            let normalized = entry.source.trim_end_matches('/').to_string();
242            if !normalized.is_empty() && !normalized.starts_with('/') {
243                repo_urls.insert(normalized);
244            }
245        }
246    }
247
248    // 3. Fallback: bundled sources (ensures at least these are checked)
249    for url in AUTO_INSTALL_SOURCES {
250        repo_urls.insert(url.to_string());
251    }
252
253    // 4. Transform repo URLs → index URLs
254    let mut index_urls: Vec<String> = repo_urls
255        .iter()
256        .filter_map(|url| repo_to_index_url(url))
257        .collect();
258    index_urls.sort();
259    index_urls.dedup();
260    index_urls
261}
262
263// ─── Per-source cache ─────────────────────────────────────────
264//
265// Each remote index is cached separately at
266// `~/.algocline/hub_cache/{hash}.json` where hash is derived from
267// the index URL. This avoids mixing data from different registries
268// and allows per-source TTL validation.
269
270fn cache_dir() -> Result<PathBuf, String> {
271    let home = dirs::home_dir().ok_or("Cannot determine home directory")?;
272    Ok(home.join(".algocline").join("hub_cache"))
273}
274
275fn cache_key(url: &str) -> String {
276    // Simple hash: use the URL bytes to produce a stable hex string.
277    // Avoids pulling in a hash crate — good enough for cache file naming.
278    let mut h: u64 = 0xcbf2_9ce4_8422_2325; // FNV-1a offset basis
279    for b in url.as_bytes() {
280        h ^= *b as u64;
281        h = h.wrapping_mul(0x0100_0000_01b3); // FNV prime
282    }
283    format!("{h:016x}")
284}
285
286/// Load cached remote index for a specific URL if fresh (within TTL).
287fn load_cached(url: &str) -> Option<HubIndex> {
288    let dir = cache_dir().ok()?;
289    let path = dir.join(format!("{}.json", cache_key(url)));
290    if !path.exists() {
291        return None;
292    }
293    let metadata = std::fs::metadata(&path).ok()?;
294    let age = metadata.modified().ok()?.elapsed().ok()?;
295    if age.as_secs() > CACHE_TTL_SECS {
296        return None;
297    }
298    let content = std::fs::read_to_string(&path).ok()?;
299    serde_json::from_str(&content).ok()
300}
301
302/// Save remote index to per-source cache file.
303fn save_cached(url: &str, index: &HubIndex) {
304    let dir = match cache_dir() {
305        Ok(d) => d,
306        Err(e) => {
307            tracing::warn!("hub cache dir unavailable: {e}");
308            return;
309        }
310    };
311    if let Err(e) = std::fs::create_dir_all(&dir) {
312        tracing::warn!("failed to create hub cache dir: {e}");
313        return;
314    }
315    let path = dir.join(format!("{}.json", cache_key(url)));
316    match serde_json::to_string_pretty(index) {
317        Ok(json) => {
318            if let Err(e) = std::fs::write(&path, json) {
319                tracing::warn!("failed to write hub cache {}: {e}", path.display());
320            }
321        }
322        Err(e) => tracing::warn!("failed to serialize hub cache: {e}"),
323    }
324}
325
326// ─── Remote fetch ──────────────────────────────────────────────
327
328/// Fetch a single remote index by URL, using per-source cache.
329fn fetch_one(url: &str) -> Result<HubIndex, String> {
330    if let Some(cached) = load_cached(url) {
331        return Ok(cached);
332    }
333
334    let agent = ureq::Agent::new_with_config(
335        ureq::config::Config::builder()
336            .timeout_global(Some(HTTP_TIMEOUT))
337            .build(),
338    );
339    let body: String = agent
340        .get(url)
341        .call()
342        .map_err(|e| format!("Failed to fetch {url}: {e}"))?
343        .body_mut()
344        .read_to_string()
345        .map_err(|e| format!("Failed to read response from {url}: {e}"))?;
346
347    let index: HubIndex = serde_json::from_str(&body)
348        .map_err(|e| format!("Failed to parse index from {url}: {e}"))?;
349
350    save_cached(url, &index);
351    Ok(index)
352}
353
354/// Fetch all discovered remote indices and merge into one.
355/// Falls back gracefully: failed sources are skipped with warnings.
356fn fetch_remote_indices() -> (HubIndex, Vec<String>) {
357    let urls = discover_index_urls();
358    let mut all_packages: Vec<IndexEntry> = Vec::new();
359    let mut seen_names: HashSet<String> = HashSet::new();
360    let mut warnings: Vec<String> = Vec::new();
361
362    for url in &urls {
363        match fetch_one(url) {
364            Ok(index) => {
365                for entry in index.packages {
366                    if seen_names.insert(entry.name.clone()) {
367                        all_packages.push(entry);
368                    }
369                    // If duplicate name across sources, first wins
370                }
371            }
372            Err(e) => {
373                warnings.push(e);
374            }
375        }
376    }
377
378    if all_packages.is_empty() && !warnings.is_empty() {
379        warnings.insert(
380            0,
381            "all remote indices unavailable, showing local packages only".to_string(),
382        );
383    }
384
385    let merged = HubIndex {
386        schema_version: "hub_index/v0".into(),
387        updated_at: String::new(),
388        packages: all_packages,
389    };
390    (merged, warnings)
391}
392
393// ─── Local state ───────────────────────────────────────────────
394
395/// Build a set of locally installed package names from `installed.json`
396/// and the `~/.algocline/packages/` directory.
397fn installed_packages() -> HashMap<String, Option<String>> {
398    let mut map = HashMap::new();
399
400    // From manifest (has version info)
401    if let Ok(m) = manifest::load_manifest() {
402        for (name, entry) in &m.packages {
403            map.insert(name.clone(), entry.version.clone());
404        }
405    }
406
407    // Also scan packages/ dir in case manifest is stale
408    if let Some(home) = dirs::home_dir() {
409        let pkg_dir = home.join(".algocline").join("packages");
410        if let Ok(entries) = std::fs::read_dir(&pkg_dir) {
411            for entry in entries.flatten() {
412                if entry.path().is_dir() {
413                    if let Some(name) = entry.file_name().to_str() {
414                        map.entry(name.to_string()).or_insert(None);
415                    }
416                }
417            }
418        }
419    }
420
421    map
422}
423
424/// Count local cards per package from `~/.algocline/cards/{pkg}/`.
425fn local_card_counts() -> HashMap<String, usize> {
426    let mut map = HashMap::new();
427    let home = match dirs::home_dir() {
428        Some(h) => h,
429        None => return map,
430    };
431    let cards_dir = home.join(".algocline").join("cards");
432    let entries = match std::fs::read_dir(&cards_dir) {
433        Ok(e) => e,
434        Err(_) => return map,
435    };
436    for entry in entries.flatten() {
437        if !entry.path().is_dir() {
438            continue;
439        }
440        let pkg = match entry.file_name().to_str() {
441            Some(n) => n.to_string(),
442            None => continue,
443        };
444        let count = std::fs::read_dir(entry.path())
445            .map(|es| {
446                es.flatten()
447                    .filter(|e| e.path().extension().is_some_and(|ext| ext == "toml"))
448                    .count()
449            })
450            .unwrap_or(0);
451        if count > 0 {
452            map.insert(pkg, count);
453        }
454    }
455    map
456}
457
458// ─── Merge ─────────────────────────────────────────────────────
459
460/// Merge remote index with local install state.
461fn merge(remote: &HubIndex) -> Vec<SearchResult> {
462    let installed = installed_packages();
463    let card_counts = local_card_counts();
464
465    let mut seen: HashSet<String> = HashSet::new();
466    let mut results: Vec<SearchResult> = Vec::new();
467
468    for entry in &remote.packages {
469        let is_installed = installed.contains_key(&entry.name);
470        let local_cards = card_counts.get(&entry.name).copied().unwrap_or(0);
471
472        seen.insert(entry.name.clone());
473        results.push(SearchResult {
474            name: entry.name.clone(),
475            version: entry.version.clone(),
476            description: entry.description.clone(),
477            category: entry.category.clone(),
478            source: entry.source.clone(),
479            installed: is_installed,
480            card_count: if is_installed && local_cards > entry.card_count {
481                local_cards
482            } else {
483                entry.card_count
484            },
485            best_card: entry.best_card.clone(),
486        });
487    }
488
489    // Add local-only packages (not in remote index)
490    for (name, version) in &installed {
491        if seen.contains(name) {
492            continue;
493        }
494        results.push(SearchResult {
495            name: name.clone(),
496            version: version.clone().unwrap_or_default(),
497            description: String::new(),
498            category: String::new(),
499            source: String::new(),
500            installed: true,
501            card_count: card_counts.get(name).copied().unwrap_or(0),
502            best_card: None,
503        });
504    }
505
506    results
507}
508
509// ─── Search (filtering) ───────────────────────────────────────
510
511fn matches_query(result: &SearchResult, query: &str) -> bool {
512    let q = query.to_lowercase();
513    result.name.to_lowercase().contains(&q)
514        || result.description.to_lowercase().contains(&q)
515        || result.category.to_lowercase().contains(&q)
516}
517
518// ─── Index generation (reindex) ───────────────────────────────
519
520/// Parse `M.meta = { ... }` from an `init.lua` file without Lua VM.
521///
522/// Extracts (name, version, description, category) from the first
523/// `M.meta = { ... }` block found in the first ~2 KB.
524///
525/// **Limitation**: Only supports flat key-value pairs inside `M.meta`.
526/// Nested tables (e.g. `tags = { ... }`) will cause the block to be
527/// truncated at the inner `}`. This is intentional — `M.meta` fields
528/// are expected to be simple strings.
529fn parse_meta_from_init_lua(path: &std::path::Path) -> Option<(String, String, String, String)> {
530    let content = std::fs::read_to_string(path).ok()?;
531    // Limit search to first ~2KB (snap back to a char boundary)
532    let mut limit = 2048.min(content.len());
533    while limit > 0 && !content.is_char_boundary(limit) {
534        limit -= 1;
535    }
536    let head = &content[..limit];
537
538    // Find M.meta = { ... } block (with brace-depth tracking)
539    let meta_start = head.find("M.meta")?;
540    let brace_start = head[meta_start..].find('{')? + meta_start;
541
542    // Track brace depth to handle nested tables correctly
543    let mut depth = 0;
544    let mut brace_end = None;
545    for (i, ch) in head[brace_start..].char_indices() {
546        match ch {
547            '{' => depth += 1,
548            '}' => {
549                depth -= 1;
550                if depth == 0 {
551                    brace_end = Some(brace_start + i);
552                    break;
553                }
554            }
555            _ => {}
556        }
557    }
558    let brace_end = brace_end?;
559    let block = &head[brace_start + 1..brace_end];
560
561    let extract = |field: &str| -> String {
562        // Match: field = "value" with word-boundary check.
563        // Walk through all occurrences and pick one that is either at
564        // the start of a line (after whitespace) or preceded by a
565        // non-alphanumeric character, preventing "description" from
566        // matching inside "short_description".
567        let mut search_from = 0;
568        while let Some(rel) = block[search_from..].find(field) {
569            let pos = search_from + rel;
570            // Check that the character before the match is not alphanumeric/underscore
571            let word_boundary = if pos == 0 {
572                true
573            } else {
574                let prev = block.as_bytes()[pos - 1];
575                !(prev.is_ascii_alphanumeric() || prev == b'_')
576            };
577            if word_boundary {
578                let after = &block[pos + field.len()..];
579                if let Some(q_start_rel) = after.find('"') {
580                    let q_start = q_start_rel + 1;
581                    if let Some(q_end_rel) = after[q_start..].find('"') {
582                        return after[q_start..q_start + q_end_rel].to_string();
583                    }
584                }
585            }
586            search_from = pos + field.len();
587        }
588        String::new()
589    };
590
591    let name = extract("name");
592    if name.is_empty() {
593        return None;
594    }
595    Some((
596        name,
597        extract("version"),
598        extract("description"),
599        extract("category"),
600    ))
601}
602
603/// Build a hub index by scanning a packages directory.
604///
605/// When `source_dir` is provided, scans that directory directly
606/// (for generating an index from a repo checkout).  Metadata comes
607/// only from `init.lua` — no manifest lookup, no card counts.
608///
609/// When `source_dir` is `None`, scans `~/.algocline/packages/` and
610/// enriches entries with manifest source and local card counts.
611fn build_index(source_dir: Option<&std::path::Path>) -> HubIndex {
612    let empty = || HubIndex {
613        schema_version: "hub_index/v0".into(),
614        updated_at: super::manifest::now_iso8601(),
615        packages: Vec::new(),
616    };
617
618    let pkg_dir = match source_dir {
619        Some(d) => d.to_path_buf(),
620        None => {
621            let home = match dirs::home_dir() {
622                Some(h) => h,
623                None => return empty(),
624            };
625            home.join(".algocline").join("packages")
626        }
627    };
628
629    let use_local_state = source_dir.is_none();
630    let card_counts = if use_local_state {
631        local_card_counts()
632    } else {
633        HashMap::new()
634    };
635    let manifest = if use_local_state {
636        manifest::load_manifest().unwrap_or_default()
637    } else {
638        manifest::Manifest::default()
639    };
640
641    let mut entries = Vec::new();
642
643    let dir_entries = match std::fs::read_dir(&pkg_dir) {
644        Ok(e) => e,
645        Err(_) => return empty(),
646    };
647
648    for entry in dir_entries.flatten() {
649        if !entry.path().is_dir() {
650            continue;
651        }
652        let dir_name = match entry.file_name().to_str() {
653            Some(n) if !n.starts_with('.') && !n.starts_with('_') => n.to_string(),
654            _ => continue,
655        };
656
657        let init_lua = entry.path().join("init.lua");
658        if !init_lua.exists() {
659            continue;
660        }
661
662        let (name, version, description, category) = parse_meta_from_init_lua(&init_lua)
663            .unwrap_or_else(|| {
664                (
665                    dir_name.clone(),
666                    String::new(),
667                    String::new(),
668                    String::new(),
669                )
670            });
671
672        // Use manifest source only for local-state mode
673        let source = manifest
674            .packages
675            .get(&dir_name)
676            .map(|e| e.source.clone())
677            .unwrap_or_default();
678
679        entries.push(IndexEntry {
680            name,
681            version,
682            description,
683            category,
684            source,
685            card_count: card_counts.get(&dir_name).copied().unwrap_or(0),
686            best_card: None,
687        });
688    }
689
690    entries.sort_by(|a, b| a.name.cmp(&b.name));
691
692    HubIndex {
693        schema_version: "hub_index/v0".into(),
694        updated_at: super::manifest::now_iso8601(),
695        packages: entries,
696    }
697}
698
699// ─── Public API ────────────────────────────────────────────────
700
701impl AppService {
702    /// Generate a hub index from a packages directory.
703    ///
704    /// When `source_dir` is provided, scans that directory (e.g. a
705    /// repo checkout) — pure metadata extraction, no manifest or card
706    /// data mixed in.  When omitted, scans `~/.algocline/packages/`.
707    ///
708    /// Writes the index to `output_path` (for CI / publishing).
709    /// Does NOT touch the remote search cache.
710    pub fn hub_reindex(
711        &self,
712        output_path: Option<&str>,
713        source_dir: Option<&str>,
714    ) -> Result<String, String> {
715        let src = source_dir.map(std::path::Path::new);
716        if let Some(d) = src {
717            if !d.is_dir() {
718                return Err(format!("source_dir '{}' is not a directory", d.display()));
719            }
720        }
721        let index = build_index(src);
722
723        let written_path = if let Some(path) = output_path {
724            let json = serde_json::to_string_pretty(&index)
725                .map_err(|e| format!("Failed to serialize index: {e}"))?;
726            std::fs::write(path, &json)
727                .map_err(|e| format!("Failed to write index to {path}: {e}"))?;
728            Some(path.to_string())
729        } else {
730            None
731        };
732
733        let response = serde_json::json!({
734            "package_count": index.packages.len(),
735            "updated_at": index.updated_at,
736            "output_path": written_path,
737            "source_dir": source_dir,
738        });
739        Ok(response.to_string())
740    }
741
742    /// Search packages across remote indices + local state.
743    ///
744    /// Index URLs are discovered from hub registries, manifest sources,
745    /// and `AUTO_INSTALL_SOURCES`. Each source is cached independently.
746    pub fn hub_search(
747        &self,
748        query: Option<&str>,
749        category: Option<&str>,
750        installed_only: Option<bool>,
751        limit: Option<usize>,
752    ) -> Result<String, String> {
753        let (remote, warnings) = fetch_remote_indices();
754        let mut results = merge(&remote);
755
756        // Filter by query
757        if let Some(q) = query {
758            if !q.is_empty() {
759                results.retain(|r| matches_query(r, q));
760            }
761        }
762
763        // Filter by category
764        if let Some(cat) = category {
765            let cat_lower = cat.to_lowercase();
766            results.retain(|r| r.category.to_lowercase() == cat_lower);
767        }
768
769        // Filter by installed state
770        if let Some(true) = installed_only {
771            results.retain(|r| r.installed);
772        }
773
774        // Sort: installed first, then by name
775        results.sort_by(|a, b| {
776            b.installed
777                .cmp(&a.installed)
778                .then_with(|| a.name.cmp(&b.name))
779        });
780
781        // Limit
782        let total = results.len();
783        let limit = limit.unwrap_or(50);
784        results.truncate(limit);
785
786        // Collect discovered sources for transparency
787        let sources = discover_index_urls();
788
789        let mut json = serde_json::json!({
790            "results": results,
791            "total": total,
792            "sources": sources,
793        });
794        if !warnings.is_empty() {
795            json["warnings"] = serde_json::json!(warnings);
796        }
797        Ok(json.to_string())
798    }
799}
800
801#[cfg(test)]
802mod tests {
803    use super::*;
804
805    #[test]
806    fn repo_to_index_url_github() {
807        assert_eq!(
808            repo_to_index_url("https://github.com/ynishi/algocline-bundled-packages"),
809            Some(
810                "https://raw.githubusercontent.com/ynishi/algocline-bundled-packages/main/hub_index.json"
811                    .to_string()
812            )
813        );
814    }
815
816    #[test]
817    fn repo_to_index_url_github_trailing_slash() {
818        assert_eq!(
819            repo_to_index_url("https://github.com/user/repo/"),
820            Some("https://raw.githubusercontent.com/user/repo/main/hub_index.json".to_string())
821        );
822    }
823
824    #[test]
825    fn repo_to_index_url_github_dot_git() {
826        assert_eq!(
827            repo_to_index_url("https://github.com/user/repo.git"),
828            Some("https://raw.githubusercontent.com/user/repo/main/hub_index.json".to_string())
829        );
830    }
831
832    #[test]
833    fn repo_to_index_url_direct_json() {
834        assert_eq!(
835            repo_to_index_url("https://example.com/my_index.json"),
836            Some("https://example.com/my_index.json".to_string())
837        );
838    }
839
840    #[test]
841    fn repo_to_index_url_unknown_host_no_json() {
842        assert_eq!(repo_to_index_url("https://example.com/some-repo"), None);
843    }
844
845    #[test]
846    fn repo_to_index_url_local_path() {
847        assert_eq!(repo_to_index_url("/home/user/my-pkg"), None);
848    }
849
850    #[test]
851    fn cache_key_stable() {
852        let k1 = cache_key("https://example.com/index.json");
853        let k2 = cache_key("https://example.com/index.json");
854        assert_eq!(k1, k2);
855        assert_eq!(k1.len(), 16); // 16 hex chars
856    }
857
858    #[test]
859    fn cache_key_different_urls() {
860        let k1 = cache_key("https://a.com/index.json");
861        let k2 = cache_key("https://b.com/index.json");
862        assert_ne!(k1, k2);
863    }
864
865    #[test]
866    fn parse_meta_flat() {
867        let tmp = tempfile::tempdir().unwrap();
868        let path = tmp.path().join("init.lua");
869        std::fs::write(
870            &path,
871            r#"
872local M = {}
873M.meta = {
874    name = "my_pkg",
875    version = "1.0.0",
876    description = "A test package",
877    category = "reasoning",
878}
879return M
880"#,
881        )
882        .unwrap();
883
884        let result = parse_meta_from_init_lua(&path).unwrap();
885        assert_eq!(result.0, "my_pkg");
886        assert_eq!(result.1, "1.0.0");
887        assert_eq!(result.2, "A test package");
888        assert_eq!(result.3, "reasoning");
889    }
890
891    #[test]
892    fn parse_meta_nested_table() {
893        let tmp = tempfile::tempdir().unwrap();
894        let path = tmp.path().join("init.lua");
895        std::fs::write(
896            &path,
897            r#"
898local M = {}
899M.meta = {
900    name = "nested_pkg",
901    tags = { "a", "b" },
902    description = "After nested",
903}
904return M
905"#,
906        )
907        .unwrap();
908
909        let result = parse_meta_from_init_lua(&path).unwrap();
910        assert_eq!(result.0, "nested_pkg");
911        assert_eq!(result.2, "After nested");
912    }
913
914    #[test]
915    fn parse_meta_word_boundary() {
916        let tmp = tempfile::tempdir().unwrap();
917        let path = tmp.path().join("init.lua");
918        std::fs::write(
919            &path,
920            r#"
921local M = {}
922M.meta = {
923    name = "wb_pkg",
924    short_description = "should not match",
925    description = "correct one",
926}
927return M
928"#,
929        )
930        .unwrap();
931
932        let result = parse_meta_from_init_lua(&path).unwrap();
933        assert_eq!(result.0, "wb_pkg");
934        assert_eq!(result.2, "correct one");
935    }
936
937    #[test]
938    fn merge_dedup_uses_hashset() {
939        // Verify that merge correctly handles local-only packages
940        // without O(n*m) behavior (structural test).
941        let remote = HubIndex {
942            schema_version: "hub_index/v0".into(),
943            updated_at: String::new(),
944            packages: vec![IndexEntry {
945                name: "remote_only".into(),
946                version: "1.0".into(),
947                description: "from remote".into(),
948                category: "test".into(),
949                source: String::new(),
950                card_count: 0,
951                best_card: None,
952            }],
953        };
954
955        let results = merge(&remote);
956        // Should include remote_only + any locally installed packages
957        assert!(results.iter().any(|r| r.name == "remote_only"));
958    }
959}