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}
122
123/// Best card summary within a package.
124#[derive(Debug, Clone, Serialize, Deserialize)]
125pub(crate) struct BestCard {
126    pub card_id: String,
127    #[serde(default)]
128    pub model: String,
129    #[serde(default)]
130    pub pass_rate: f64,
131    #[serde(default)]
132    pub scenario: String,
133}
134
135/// Search result — index entry enriched with local install state.
136#[derive(Debug, Clone, Serialize)]
137struct SearchResult {
138    name: String,
139    version: String,
140    description: String,
141    category: String,
142    source: String,
143    installed: bool,
144    card_count: usize,
145    best_card: Option<BestCard>,
146}
147
148// ─── Hub registries ───────────────────────────────────────────
149//
150// Persistent file (`~/.algocline/hub_registries.json`) that records
151// source URLs from `pkg_install` and `card_install`.  This is the
152// primary source for Hub index URL discovery — the manifest and
153// `AUTO_INSTALL_SOURCES` serve as fallback seeds.
154
155/// One entry in `hub_registries.json`.
156#[derive(Debug, Clone, Serialize, Deserialize)]
157pub(crate) struct RegistryEntry {
158    /// Original source URL (Git repo or local path).
159    pub source: String,
160    /// How it was registered: "pkg_install" or "card_install".
161    pub origin: String,
162    /// ISO 8601 timestamp of when the entry was added.
163    pub added_at: String,
164}
165
166/// Top-level registries file.
167#[derive(Debug, Clone, Serialize, Deserialize, Default)]
168pub(crate) struct HubRegistries {
169    pub registries: Vec<RegistryEntry>,
170}
171
172fn registries_path() -> Result<PathBuf, String> {
173    let home = dirs::home_dir().ok_or("Cannot determine home directory")?;
174    Ok(home.join(".algocline").join("hub_registries.json"))
175}
176
177/// Load registries from disk.  Returns empty list if file is missing.
178fn load_registries() -> HubRegistries {
179    let path = match registries_path() {
180        Ok(p) => p,
181        Err(_) => return HubRegistries::default(),
182    };
183    if !path.exists() {
184        return HubRegistries::default();
185    }
186    std::fs::read_to_string(&path)
187        .ok()
188        .and_then(|c| serde_json::from_str(&c).ok())
189        .unwrap_or_default()
190}
191
192/// Register a source URL.  Deduplicates by normalized URL.
193///
194/// Uses atomic write (tempfile + rename) to avoid partial writes if
195/// the process is interrupted.  Read-modify-write is not locked across
196/// processes, but MCP servers are single-process so this is safe in
197/// practice.
198pub(crate) fn register_source(source: &str, origin: &str) {
199    let normalized = source.trim_end_matches('/').to_string();
200    if normalized.is_empty() {
201        return;
202    }
203    // Skip local paths — they can't host a remote index
204    if normalized.starts_with('/') || normalized.starts_with('.') {
205        return;
206    }
207
208    let path = match registries_path() {
209        Ok(p) => p,
210        Err(_) => return,
211    };
212    if let Some(parent) = path.parent() {
213        let _ = std::fs::create_dir_all(parent);
214    }
215
216    // Re-read from disk right before write to minimize TOCTOU window
217    let mut reg = load_registries();
218
219    // Already registered?
220    if reg
221        .registries
222        .iter()
223        .any(|e| e.source.trim_end_matches('/') == normalized)
224    {
225        return;
226    }
227
228    reg.registries.push(RegistryEntry {
229        source: normalized,
230        origin: origin.to_string(),
231        added_at: manifest::now_iso8601(),
232    });
233
234    // Atomic write: write to temp file, then rename
235    match serde_json::to_string_pretty(&reg) {
236        Ok(json) => {
237            let tmp_path = path.with_extension("json.tmp");
238            if let Err(e) = std::fs::write(&tmp_path, &json) {
239                tracing::warn!("failed to write hub registries tmp: {e}");
240                return;
241            }
242            if let Err(e) = std::fs::rename(&tmp_path, &path) {
243                tracing::warn!("failed to rename hub registries: {e}");
244                // Clean up tmp on failure
245                let _ = std::fs::remove_file(&tmp_path);
246            }
247        }
248        Err(e) => tracing::warn!("failed to serialize hub registries: {e}"),
249    }
250}
251
252// ─── Hub config ──────────────────────────────────────────────
253//
254// Optional `[hub]` section in `~/.algocline/config.toml`:
255//
256//   [hub]
257//   collection_url = "https://raw.githubusercontent.com/.../hub_index.json"
258//
259// When set, this is fetched as Tier 0 (the aggregated collection
260// index containing all known packages, including uninstalled ones).
261
262/// Read the `[hub].collection_url` from `~/.algocline/config.toml`.
263fn collection_url_from_config() -> Option<String> {
264    let home = dirs::home_dir()?;
265    let path = home.join(".algocline").join("config.toml");
266    let content = std::fs::read_to_string(&path).ok()?;
267    let doc: toml_edit::DocumentMut = content.parse().ok()?;
268    let url = doc
269        .get("hub")?
270        .get("collection_url")?
271        .as_str()?
272        .trim()
273        .to_string();
274    if url.is_empty() {
275        None
276    } else {
277        Some(url)
278    }
279}
280
281// ─── Index URL discovery ──────────────────────────────────────
282//
283// Derives remote index URLs from:
284//   0. Hub Collection URL (from config.toml) — aggregated index
285//   1. Hub registries (`hub_registries.json`) — primary source
286//   2. Unique `source` fields in the installed-packages manifest
287//   3. `AUTO_INSTALL_SOURCES` as fallback seeds (for first run)
288//
289// GitHub repos are transformed:
290//   https://github.com/{owner}/{repo}  →
291//   https://raw.githubusercontent.com/{owner}/{repo}/main/hub_index.json
292
293/// Convert a GitHub repo URL to a raw `hub_index.json` URL.
294/// Returns `None` for non-GitHub URLs (future: support other hosts).
295fn repo_to_index_url(repo_url: &str) -> Option<String> {
296    let trimmed = repo_url.trim_end_matches('/').trim_end_matches(".git");
297    if let Some(path) = trimmed.strip_prefix("https://github.com/") {
298        // path = "owner/repo"
299        let parts: Vec<&str> = path.splitn(3, '/').collect();
300        if parts.len() >= 2 {
301            return Some(format!(
302                "https://raw.githubusercontent.com/{}/{}/main/hub_index.json",
303                parts[0], parts[1]
304            ));
305        }
306    }
307    // Non-GitHub URL: assume it's already a direct index URL
308    if trimmed.ends_with(".json") {
309        Some(trimmed.to_string())
310    } else {
311        None
312    }
313}
314
315/// Collect unique index URLs from config + registries + manifest + bundled seeds.
316fn discover_index_urls() -> Vec<String> {
317    let mut index_urls: Vec<String> = Vec::new();
318
319    // 0. From config.toml [hub].collection_url (Tier 0 — aggregated collection)
320    if let Some(url) = collection_url_from_config() {
321        index_urls.push(url);
322    }
323
324    let mut repo_urls: HashSet<String> = HashSet::new();
325
326    // 1. From hub registries (primary)
327    let reg = load_registries();
328    for entry in &reg.registries {
329        let normalized = entry.source.trim_end_matches('/').to_string();
330        if !normalized.is_empty() {
331            repo_urls.insert(normalized);
332        }
333    }
334
335    // 2. From manifest (catch sources registered before hub_registries existed)
336    if let Ok(m) = manifest::load_manifest() {
337        for entry in m.packages.values() {
338            let normalized = entry.source.trim_end_matches('/').to_string();
339            if !normalized.is_empty() && !normalized.starts_with('/') {
340                repo_urls.insert(normalized);
341            }
342        }
343    }
344
345    // 3. Fallback: bundled sources (ensures at least these are checked)
346    for url in AUTO_INSTALL_SOURCES {
347        repo_urls.insert(url.to_string());
348    }
349
350    // 4. Transform repo URLs → index URLs, dedup against Tier 0
351    let existing: HashSet<String> = index_urls.iter().cloned().collect();
352    let mut derived: Vec<String> = repo_urls
353        .iter()
354        .filter_map(|url| repo_to_index_url(url))
355        .filter(|url| !existing.contains(url))
356        .collect();
357    derived.sort();
358    derived.dedup();
359    index_urls.extend(derived);
360
361    index_urls
362}
363
364// ─── Per-source cache ─────────────────────────────────────────
365//
366// Each remote index is cached separately at
367// `~/.algocline/hub_cache/{hash}.json` where hash is derived from
368// the index URL. This avoids mixing data from different registries
369// and allows per-source TTL validation.
370
371fn cache_dir() -> Result<PathBuf, String> {
372    let home = dirs::home_dir().ok_or("Cannot determine home directory")?;
373    Ok(home.join(".algocline").join("hub_cache"))
374}
375
376fn cache_key(url: &str) -> String {
377    // Simple hash: use the URL bytes to produce a stable hex string.
378    // Avoids pulling in a hash crate — good enough for cache file naming.
379    let mut h: u64 = 0xcbf2_9ce4_8422_2325; // FNV-1a offset basis
380    for b in url.as_bytes() {
381        h ^= *b as u64;
382        h = h.wrapping_mul(0x0100_0000_01b3); // FNV prime
383    }
384    format!("{h:016x}")
385}
386
387/// Load cached remote index for a specific URL if fresh (within TTL).
388fn load_cached(url: &str) -> Option<HubIndex> {
389    let dir = cache_dir().ok()?;
390    let path = dir.join(format!("{}.json", cache_key(url)));
391    if !path.exists() {
392        return None;
393    }
394    let metadata = std::fs::metadata(&path).ok()?;
395    let age = metadata.modified().ok()?.elapsed().ok()?;
396    if age.as_secs() > CACHE_TTL_SECS {
397        return None;
398    }
399    let content = std::fs::read_to_string(&path).ok()?;
400    serde_json::from_str(&content).ok()
401}
402
403/// Save remote index to per-source cache file.
404fn save_cached(url: &str, index: &HubIndex) {
405    let dir = match cache_dir() {
406        Ok(d) => d,
407        Err(e) => {
408            tracing::warn!("hub cache dir unavailable: {e}");
409            return;
410        }
411    };
412    if let Err(e) = std::fs::create_dir_all(&dir) {
413        tracing::warn!("failed to create hub cache dir: {e}");
414        return;
415    }
416    let path = dir.join(format!("{}.json", cache_key(url)));
417    match serde_json::to_string_pretty(index) {
418        Ok(json) => {
419            if let Err(e) = std::fs::write(&path, json) {
420                tracing::warn!("failed to write hub cache {}: {e}", path.display());
421            }
422        }
423        Err(e) => tracing::warn!("failed to serialize hub cache: {e}"),
424    }
425}
426
427// ─── Remote fetch ──────────────────────────────────────────────
428
429/// Fetch a single remote index by URL, using per-source cache.
430fn fetch_one(url: &str) -> Result<HubIndex, String> {
431    if let Some(cached) = load_cached(url) {
432        return Ok(cached);
433    }
434
435    let agent = ureq::Agent::new_with_config(
436        ureq::config::Config::builder()
437            .timeout_global(Some(HTTP_TIMEOUT))
438            .build(),
439    );
440    let body: String = agent
441        .get(url)
442        .call()
443        .map_err(|e| format!("Failed to fetch {url}: {e}"))?
444        .body_mut()
445        .read_to_string()
446        .map_err(|e| format!("Failed to read response from {url}: {e}"))?;
447
448    let index: HubIndex = serde_json::from_str(&body)
449        .map_err(|e| format!("Failed to parse index from {url}: {e}"))?;
450
451    save_cached(url, &index);
452    Ok(index)
453}
454
455/// Fetch all discovered remote indices and merge into one.
456/// Falls back gracefully: failed sources are skipped with warnings.
457fn fetch_remote_indices() -> (HubIndex, Vec<String>) {
458    let urls = discover_index_urls();
459    let mut all_packages: Vec<IndexEntry> = Vec::new();
460    let mut seen_names: HashSet<String> = HashSet::new();
461    let mut warnings: Vec<String> = Vec::new();
462
463    for url in &urls {
464        match fetch_one(url) {
465            Ok(index) => {
466                for entry in index.packages {
467                    if seen_names.insert(entry.name.clone()) {
468                        all_packages.push(entry);
469                    }
470                    // If duplicate name across sources, first wins
471                }
472            }
473            Err(e) => {
474                warnings.push(e);
475            }
476        }
477    }
478
479    if all_packages.is_empty() && !warnings.is_empty() {
480        warnings.insert(
481            0,
482            "all remote indices unavailable, showing local packages only".to_string(),
483        );
484    }
485
486    let merged = HubIndex {
487        schema_version: "hub_index/v0".into(),
488        updated_at: String::new(),
489        packages: all_packages,
490    };
491    (merged, warnings)
492}
493
494// ─── Local state ───────────────────────────────────────────────
495
496/// Build a set of locally installed package names from `installed.json`
497/// and the `~/.algocline/packages/` directory.
498fn installed_packages() -> HashMap<String, Option<String>> {
499    let mut map = HashMap::new();
500
501    // From manifest (has version info)
502    if let Ok(m) = manifest::load_manifest() {
503        for (name, entry) in &m.packages {
504            map.insert(name.clone(), entry.version.clone());
505        }
506    }
507
508    // Also scan packages/ dir in case manifest is stale
509    if let Some(home) = dirs::home_dir() {
510        let pkg_dir = home.join(".algocline").join("packages");
511        if let Ok(entries) = std::fs::read_dir(&pkg_dir) {
512            for entry in entries.flatten() {
513                if entry.path().is_dir() {
514                    if let Some(name) = entry.file_name().to_str() {
515                        map.entry(name.to_string()).or_insert(None);
516                    }
517                }
518            }
519        }
520    }
521
522    map
523}
524
525/// Count local cards per package from `~/.algocline/cards/{pkg}/`.
526fn local_card_counts() -> HashMap<String, usize> {
527    let mut map = HashMap::new();
528    let home = match dirs::home_dir() {
529        Some(h) => h,
530        None => return map,
531    };
532    let cards_dir = home.join(".algocline").join("cards");
533    let entries = match std::fs::read_dir(&cards_dir) {
534        Ok(e) => e,
535        Err(_) => return map,
536    };
537    for entry in entries.flatten() {
538        if !entry.path().is_dir() {
539            continue;
540        }
541        let pkg = match entry.file_name().to_str() {
542            Some(n) => n.to_string(),
543            None => continue,
544        };
545        let count = std::fs::read_dir(entry.path())
546            .map(|es| {
547                es.flatten()
548                    .filter(|e| e.path().extension().is_some_and(|ext| ext == "toml"))
549                    .count()
550            })
551            .unwrap_or(0);
552        if count > 0 {
553            map.insert(pkg, count);
554        }
555    }
556    map
557}
558
559/// Count eval results for a specific package by scanning `~/.algocline/evals/`.
560///
561/// Reads only `.meta.json` files (lightweight) to check the strategy field.
562/// Falls back to reading full eval JSON if meta is missing.
563fn count_evals_for_pkg(pkg: &str) -> usize {
564    let home = match dirs::home_dir() {
565        Some(h) => h,
566        None => return 0,
567    };
568    let evals_dir = home.join(".algocline").join("evals");
569    let entries = match std::fs::read_dir(&evals_dir) {
570        Ok(e) => e,
571        Err(_) => return 0,
572    };
573
574    // Collect all filenames first so ordering doesn't matter.
575    // We track stems that have a .meta.json to avoid reading the full eval JSON.
576    let mut meta_stems: HashSet<String> = HashSet::new();
577    let mut meta_matches: usize = 0;
578    let mut non_meta_paths: Vec<(PathBuf, String)> = Vec::new(); // (path, stem)
579
580    for entry in entries.flatten() {
581        let path = entry.path();
582        let name = match path.file_name().and_then(|n| n.to_str()) {
583            Some(n) => n.to_string(),
584            None => continue,
585        };
586
587        if name.ends_with(".meta.json") {
588            let stem = name.trim_end_matches(".meta.json").to_string();
589            meta_stems.insert(stem);
590            if let Ok(content) = std::fs::read_to_string(&path) {
591                if let Ok(val) = serde_json::from_str::<serde_json::Value>(&content) {
592                    if val.get("strategy").and_then(|s| s.as_str()) == Some(pkg) {
593                        meta_matches += 1;
594                    }
595                }
596            }
597            continue;
598        }
599
600        // Skip non-json or comparison files
601        if !name.ends_with(".json") || name.starts_with("compare_") {
602            continue;
603        }
604
605        let stem = path
606            .file_stem()
607            .and_then(|s| s.to_str())
608            .unwrap_or("")
609            .to_string();
610        non_meta_paths.push((path, stem));
611    }
612
613    // Only read full eval JSON for entries without a .meta.json
614    let fallback_matches = non_meta_paths
615        .iter()
616        .filter(|(_, stem)| !meta_stems.contains(stem))
617        .filter(|(path, _)| {
618            std::fs::read_to_string(path)
619                .ok()
620                .and_then(|c| serde_json::from_str::<serde_json::Value>(&c).ok())
621                .and_then(|v| v.get("strategy")?.as_str().map(|s| s == pkg))
622                .unwrap_or(false)
623        })
624        .count();
625
626    meta_matches + fallback_matches
627}
628
629// ─── Merge ─────────────────────────────────────────────────────
630
631/// Merge remote index with local install state.
632fn merge(remote: &HubIndex) -> Vec<SearchResult> {
633    let installed = installed_packages();
634    let card_counts = local_card_counts();
635
636    let mut seen: HashSet<String> = HashSet::new();
637    let mut results: Vec<SearchResult> = Vec::new();
638
639    for entry in &remote.packages {
640        let is_installed = installed.contains_key(&entry.name);
641        let local_cards = card_counts.get(&entry.name).copied().unwrap_or(0);
642
643        seen.insert(entry.name.clone());
644        results.push(SearchResult {
645            name: entry.name.clone(),
646            version: entry.version.clone(),
647            description: entry.description.clone(),
648            category: entry.category.clone(),
649            source: entry.source.clone(),
650            installed: is_installed,
651            card_count: if is_installed && local_cards > entry.card_count {
652                local_cards
653            } else {
654                entry.card_count
655            },
656            best_card: entry.best_card.clone(),
657        });
658    }
659
660    // Add local-only packages (not in remote index)
661    for (name, version) in &installed {
662        if seen.contains(name) {
663            continue;
664        }
665        results.push(SearchResult {
666            name: name.clone(),
667            version: version.clone().unwrap_or_default(),
668            description: String::new(),
669            category: String::new(),
670            source: String::new(),
671            installed: true,
672            card_count: card_counts.get(name).copied().unwrap_or(0),
673            best_card: None,
674        });
675    }
676
677    results
678}
679
680// ─── Search (filtering) ───────────────────────────────────────
681
682fn matches_query(result: &SearchResult, query: &str) -> bool {
683    let q = query.to_lowercase();
684    result.name.to_lowercase().contains(&q)
685        || result.description.to_lowercase().contains(&q)
686        || result.category.to_lowercase().contains(&q)
687}
688
689// ─── Index generation (reindex) ───────────────────────────────
690
691/// Parse `M.meta = { ... }` from an `init.lua` file without Lua VM.
692///
693/// Extracts (name, version, description, category) from the first
694/// `M.meta = { ... }` block found in the first ~2 KB.
695///
696/// **Limitation**: Only supports flat key-value pairs inside `M.meta`.
697/// Nested tables (e.g. `tags = { ... }`) will cause the block to be
698/// truncated at the inner `}`. This is intentional — `M.meta` fields
699/// are expected to be simple strings.
700fn parse_meta_from_init_lua(path: &std::path::Path) -> Option<(String, String, String, String)> {
701    let content = std::fs::read_to_string(path).ok()?;
702    // Limit search to first ~2KB (snap back to a char boundary)
703    let mut limit = 2048.min(content.len());
704    while limit > 0 && !content.is_char_boundary(limit) {
705        limit -= 1;
706    }
707    let head = &content[..limit];
708
709    // Find M.meta = { ... } block (with brace-depth tracking)
710    let meta_start = head.find("M.meta")?;
711    let brace_start = head[meta_start..].find('{')? + meta_start;
712
713    // Track brace depth to handle nested tables correctly
714    let mut depth = 0;
715    let mut brace_end = None;
716    for (i, ch) in head[brace_start..].char_indices() {
717        match ch {
718            '{' => depth += 1,
719            '}' => {
720                depth -= 1;
721                if depth == 0 {
722                    brace_end = Some(brace_start + i);
723                    break;
724                }
725            }
726            _ => {}
727        }
728    }
729    let brace_end = brace_end?;
730    let block = &head[brace_start + 1..brace_end];
731
732    let extract = |field: &str| -> String {
733        // Match: field = "value" with word-boundary check.
734        // Walk through all occurrences and pick one that is either at
735        // the start of a line (after whitespace) or preceded by a
736        // non-alphanumeric character, preventing "description" from
737        // matching inside "short_description".
738        let mut search_from = 0;
739        while let Some(rel) = block[search_from..].find(field) {
740            let pos = search_from + rel;
741            // Check that the character before the match is not alphanumeric/underscore
742            let word_boundary = if pos == 0 {
743                true
744            } else {
745                let prev = block.as_bytes()[pos - 1];
746                !(prev.is_ascii_alphanumeric() || prev == b'_')
747            };
748            if word_boundary {
749                let after = &block[pos + field.len()..];
750                if let Some(q_start_rel) = after.find('"') {
751                    let q_start = q_start_rel + 1;
752                    if let Some(q_end_rel) = after[q_start..].find('"') {
753                        return after[q_start..q_start + q_end_rel].to_string();
754                    }
755                }
756            }
757            search_from = pos + field.len();
758        }
759        String::new()
760    };
761
762    let name = extract("name");
763    if name.is_empty() {
764        return None;
765    }
766    Some((
767        name,
768        extract("version"),
769        extract("description"),
770        extract("category"),
771    ))
772}
773
774/// Build a hub index by scanning a packages directory.
775///
776/// When `source_dir` is provided, scans that directory directly
777/// (for generating an index from a repo checkout).  Metadata comes
778/// only from `init.lua` — no manifest lookup, no card counts.
779///
780/// When `source_dir` is `None`, scans `~/.algocline/packages/` and
781/// enriches entries with manifest source and local card counts.
782fn build_index(source_dir: Option<&std::path::Path>) -> HubIndex {
783    let empty = || HubIndex {
784        schema_version: "hub_index/v0".into(),
785        updated_at: super::manifest::now_iso8601(),
786        packages: Vec::new(),
787    };
788
789    let pkg_dir = match source_dir {
790        Some(d) => d.to_path_buf(),
791        None => {
792            let home = match dirs::home_dir() {
793                Some(h) => h,
794                None => return empty(),
795            };
796            home.join(".algocline").join("packages")
797        }
798    };
799
800    let use_local_state = source_dir.is_none();
801    let card_counts = if use_local_state {
802        local_card_counts()
803    } else {
804        HashMap::new()
805    };
806    let manifest = if use_local_state {
807        manifest::load_manifest().unwrap_or_default()
808    } else {
809        manifest::Manifest::default()
810    };
811
812    let mut entries = Vec::new();
813
814    let dir_entries = match std::fs::read_dir(&pkg_dir) {
815        Ok(e) => e,
816        Err(_) => return empty(),
817    };
818
819    for entry in dir_entries.flatten() {
820        if !entry.path().is_dir() {
821            continue;
822        }
823        let dir_name = match entry.file_name().to_str() {
824            Some(n) if !n.starts_with('.') && !n.starts_with('_') => n.to_string(),
825            _ => continue,
826        };
827
828        let init_lua = entry.path().join("init.lua");
829        if !init_lua.exists() {
830            continue;
831        }
832
833        let (name, version, description, category) = parse_meta_from_init_lua(&init_lua)
834            .unwrap_or_else(|| {
835                (
836                    dir_name.clone(),
837                    String::new(),
838                    String::new(),
839                    String::new(),
840                )
841            });
842
843        // Use manifest source only for local-state mode
844        let source = manifest
845            .packages
846            .get(&dir_name)
847            .map(|e| e.source.clone())
848            .unwrap_or_default();
849
850        entries.push(IndexEntry {
851            name,
852            version,
853            description,
854            category,
855            source,
856            card_count: card_counts.get(&dir_name).copied().unwrap_or(0),
857            best_card: None,
858        });
859    }
860
861    entries.sort_by(|a, b| a.name.cmp(&b.name));
862
863    HubIndex {
864        schema_version: "hub_index/v0".into(),
865        updated_at: super::manifest::now_iso8601(),
866        packages: entries,
867    }
868}
869
870// ─── Public API ────────────────────────────────────────────────
871
872impl AppService {
873    /// Generate a hub index from a packages directory.
874    ///
875    /// When `source_dir` is provided, scans that directory (e.g. a
876    /// repo checkout) — pure metadata extraction, no manifest or card
877    /// data mixed in.  When omitted, scans `~/.algocline/packages/`.
878    ///
879    /// Writes the index to `output_path` (for CI / publishing).
880    /// Does NOT touch the remote search cache.
881    pub fn hub_reindex(
882        &self,
883        output_path: Option<&str>,
884        source_dir: Option<&str>,
885    ) -> Result<String, String> {
886        let src = source_dir.map(std::path::Path::new);
887        if let Some(d) = src {
888            if !d.is_dir() {
889                return Err(format!("source_dir '{}' is not a directory", d.display()));
890            }
891        }
892        let index = build_index(src);
893
894        let written_path = if let Some(path) = output_path {
895            let json = serde_json::to_string_pretty(&index)
896                .map_err(|e| format!("Failed to serialize index: {e}"))?;
897            std::fs::write(path, &json)
898                .map_err(|e| format!("Failed to write index to {path}: {e}"))?;
899            Some(path.to_string())
900        } else {
901            None
902        };
903
904        let response = serde_json::json!({
905            "package_count": index.packages.len(),
906            "updated_at": index.updated_at,
907            "output_path": written_path,
908            "source_dir": source_dir,
909        });
910        Ok(response.to_string())
911    }
912
913    /// Show detailed information for a single package.
914    ///
915    /// Aggregates package metadata (from index or local `init.lua`),
916    /// all Cards, aliases, and eval stats into one response.
917    pub fn hub_info(&self, pkg: &str) -> Result<String, String> {
918        use algocline_engine::card;
919
920        // Guard against path traversal
921        if pkg.contains("..") || pkg.contains('/') || pkg.contains('\\') {
922            return Err(format!("Invalid package name: '{pkg}'"));
923        }
924
925        // Package metadata: try remote index first, fall back to local
926        let installed = installed_packages();
927        let is_installed = installed.contains_key(pkg);
928
929        let (version, description, category, source) = {
930            // Try to get from remote index
931            let (remote, _) = fetch_remote_indices();
932            if let Some(entry) = remote.packages.iter().find(|e| e.name == pkg) {
933                (
934                    entry.version.clone(),
935                    entry.description.clone(),
936                    entry.category.clone(),
937                    entry.source.clone(),
938                )
939            } else if is_installed {
940                // Fall back to local init.lua parse
941                let home = dirs::home_dir().ok_or("Cannot determine home directory")?;
942                let init_lua = home
943                    .join(".algocline")
944                    .join("packages")
945                    .join(pkg)
946                    .join("init.lua");
947                let meta = parse_meta_from_init_lua(&init_lua);
948                let manifest_source = manifest::load_manifest()
949                    .ok()
950                    .and_then(|m| m.packages.get(pkg).map(|e| e.source.clone()))
951                    .unwrap_or_default();
952                match meta {
953                    Some((_, v, d, c)) => (v, d, c, manifest_source),
954                    None => (
955                        installed.get(pkg).cloned().flatten().unwrap_or_default(),
956                        String::new(),
957                        String::new(),
958                        manifest_source,
959                    ),
960                }
961            } else {
962                return Err(format!(
963                    "Package '{pkg}' not found in remote indices or locally installed packages"
964                ));
965            }
966        };
967
968        // Cards for this package (single call, reused for stats)
969        let card_rows = card::list(Some(pkg)).unwrap_or_default();
970        let cards_json = card::summaries_to_json(&card_rows);
971
972        // Aliases for this package
973        let aliases_json = match card::alias_list(Some(pkg)) {
974            Ok(rows) => card::aliases_to_json(&rows),
975            Err(_) => serde_json::json!([]),
976        };
977
978        // Stats: card count, best pass_rate, eval count
979        let card_count = card_rows.len();
980        let best_pass_rate = card_rows
981            .iter()
982            .filter_map(|c| c.pass_rate)
983            .fold(f64::NEG_INFINITY, f64::max);
984        let best_pass_rate = if best_pass_rate.is_finite() {
985            Some(best_pass_rate)
986        } else {
987            None
988        };
989
990        // Eval count from evals directory
991        let eval_count = count_evals_for_pkg(pkg);
992
993        let response = serde_json::json!({
994            "pkg": {
995                "name": pkg,
996                "version": version,
997                "description": description,
998                "category": category,
999                "source": source,
1000                "installed": is_installed,
1001            },
1002            "cards": cards_json,
1003            "aliases": aliases_json,
1004            "stats": {
1005                "card_count": card_count,
1006                "eval_count": eval_count,
1007                "best_pass_rate": best_pass_rate,
1008            },
1009        });
1010        Ok(response.to_string())
1011    }
1012
1013    /// Search packages across remote indices + local state.
1014    ///
1015    /// Index URLs are discovered from hub registries, manifest sources,
1016    /// and `AUTO_INSTALL_SOURCES`. Each source is cached independently.
1017    pub fn hub_search(
1018        &self,
1019        query: Option<&str>,
1020        category: Option<&str>,
1021        installed_only: Option<bool>,
1022        limit: Option<usize>,
1023    ) -> Result<String, String> {
1024        let (remote, warnings) = fetch_remote_indices();
1025        let mut results = merge(&remote);
1026
1027        // Filter by query
1028        if let Some(q) = query {
1029            if !q.is_empty() {
1030                results.retain(|r| matches_query(r, q));
1031            }
1032        }
1033
1034        // Filter by category
1035        if let Some(cat) = category {
1036            let cat_lower = cat.to_lowercase();
1037            results.retain(|r| r.category.to_lowercase() == cat_lower);
1038        }
1039
1040        // Filter by installed state
1041        if let Some(true) = installed_only {
1042            results.retain(|r| r.installed);
1043        }
1044
1045        // Sort: installed first, then by name
1046        results.sort_by(|a, b| {
1047            b.installed
1048                .cmp(&a.installed)
1049                .then_with(|| a.name.cmp(&b.name))
1050        });
1051
1052        // Limit
1053        let total = results.len();
1054        let limit = limit.unwrap_or(50);
1055        results.truncate(limit);
1056
1057        // Collect discovered sources for transparency
1058        let sources = discover_index_urls();
1059
1060        let mut json = serde_json::json!({
1061            "results": results,
1062            "total": total,
1063            "sources": sources,
1064        });
1065        if !warnings.is_empty() {
1066            json["warnings"] = serde_json::json!(warnings);
1067        }
1068        Ok(json.to_string())
1069    }
1070}
1071
1072#[cfg(test)]
1073mod tests {
1074    use super::*;
1075
1076    #[test]
1077    fn repo_to_index_url_github() {
1078        assert_eq!(
1079            repo_to_index_url("https://github.com/ynishi/algocline-bundled-packages"),
1080            Some(
1081                "https://raw.githubusercontent.com/ynishi/algocline-bundled-packages/main/hub_index.json"
1082                    .to_string()
1083            )
1084        );
1085    }
1086
1087    #[test]
1088    fn repo_to_index_url_github_trailing_slash() {
1089        assert_eq!(
1090            repo_to_index_url("https://github.com/user/repo/"),
1091            Some("https://raw.githubusercontent.com/user/repo/main/hub_index.json".to_string())
1092        );
1093    }
1094
1095    #[test]
1096    fn repo_to_index_url_github_dot_git() {
1097        assert_eq!(
1098            repo_to_index_url("https://github.com/user/repo.git"),
1099            Some("https://raw.githubusercontent.com/user/repo/main/hub_index.json".to_string())
1100        );
1101    }
1102
1103    #[test]
1104    fn repo_to_index_url_direct_json() {
1105        assert_eq!(
1106            repo_to_index_url("https://example.com/my_index.json"),
1107            Some("https://example.com/my_index.json".to_string())
1108        );
1109    }
1110
1111    #[test]
1112    fn repo_to_index_url_unknown_host_no_json() {
1113        assert_eq!(repo_to_index_url("https://example.com/some-repo"), None);
1114    }
1115
1116    #[test]
1117    fn repo_to_index_url_local_path() {
1118        assert_eq!(repo_to_index_url("/home/user/my-pkg"), None);
1119    }
1120
1121    #[test]
1122    fn cache_key_stable() {
1123        let k1 = cache_key("https://example.com/index.json");
1124        let k2 = cache_key("https://example.com/index.json");
1125        assert_eq!(k1, k2);
1126        assert_eq!(k1.len(), 16); // 16 hex chars
1127    }
1128
1129    #[test]
1130    fn cache_key_different_urls() {
1131        let k1 = cache_key("https://a.com/index.json");
1132        let k2 = cache_key("https://b.com/index.json");
1133        assert_ne!(k1, k2);
1134    }
1135
1136    #[test]
1137    fn parse_meta_flat() {
1138        let tmp = tempfile::tempdir().unwrap();
1139        let path = tmp.path().join("init.lua");
1140        std::fs::write(
1141            &path,
1142            r#"
1143local M = {}
1144M.meta = {
1145    name = "my_pkg",
1146    version = "1.0.0",
1147    description = "A test package",
1148    category = "reasoning",
1149}
1150return M
1151"#,
1152        )
1153        .unwrap();
1154
1155        let result = parse_meta_from_init_lua(&path).unwrap();
1156        assert_eq!(result.0, "my_pkg");
1157        assert_eq!(result.1, "1.0.0");
1158        assert_eq!(result.2, "A test package");
1159        assert_eq!(result.3, "reasoning");
1160    }
1161
1162    #[test]
1163    fn parse_meta_nested_table() {
1164        let tmp = tempfile::tempdir().unwrap();
1165        let path = tmp.path().join("init.lua");
1166        std::fs::write(
1167            &path,
1168            r#"
1169local M = {}
1170M.meta = {
1171    name = "nested_pkg",
1172    tags = { "a", "b" },
1173    description = "After nested",
1174}
1175return M
1176"#,
1177        )
1178        .unwrap();
1179
1180        let result = parse_meta_from_init_lua(&path).unwrap();
1181        assert_eq!(result.0, "nested_pkg");
1182        assert_eq!(result.2, "After nested");
1183    }
1184
1185    #[test]
1186    fn parse_meta_word_boundary() {
1187        let tmp = tempfile::tempdir().unwrap();
1188        let path = tmp.path().join("init.lua");
1189        std::fs::write(
1190            &path,
1191            r#"
1192local M = {}
1193M.meta = {
1194    name = "wb_pkg",
1195    short_description = "should not match",
1196    description = "correct one",
1197}
1198return M
1199"#,
1200        )
1201        .unwrap();
1202
1203        let result = parse_meta_from_init_lua(&path).unwrap();
1204        assert_eq!(result.0, "wb_pkg");
1205        assert_eq!(result.2, "correct one");
1206    }
1207
1208    #[test]
1209    fn merge_dedup_uses_hashset() {
1210        // Verify that merge correctly handles local-only packages
1211        // without O(n*m) behavior (structural test).
1212        let remote = HubIndex {
1213            schema_version: "hub_index/v0".into(),
1214            updated_at: String::new(),
1215            packages: vec![IndexEntry {
1216                name: "remote_only".into(),
1217                version: "1.0".into(),
1218                description: "from remote".into(),
1219                category: "test".into(),
1220                source: String::new(),
1221                card_count: 0,
1222                best_card: None,
1223            }],
1224        };
1225
1226        let results = merge(&remote);
1227        // Should include remote_only + any locally installed packages
1228        assert!(results.iter().any(|r| r.name == "remote_only"));
1229    }
1230}