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 algocline_core::{AppDir, PkgEntity};
82
83use super::list_opts::{
84    apply_sort_by_value, matches_filter, parse_sort, project_fields, resolve_fields, ListOpts,
85    HUB_SEARCH_FULL, HUB_SEARCH_SUMMARY,
86};
87use super::manifest;
88use super::resolve::AUTO_INSTALL_SOURCES;
89use super::source::PackageSource;
90use super::AppService;
91use super::HubRegistriesError;
92
93// ─── Constants ─────────────────────────────────────────────────
94
95/// Cache TTL in seconds (1 hour).
96const CACHE_TTL_SECS: u64 = 3600;
97
98/// HTTP request timeout (30 seconds).
99const HTTP_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(30);
100
101// ─── Index schema ──────────────────────────────────────────────
102
103/// Remote index — same shape as the local index so merge is trivial.
104#[derive(Debug, Clone, Serialize, Deserialize)]
105pub(crate) struct HubIndex {
106    pub schema_version: String,
107    #[serde(default)]
108    pub updated_at: String,
109    #[serde(default)]
110    pub packages: Vec<IndexEntry>,
111}
112
113/// One package in the index.
114///
115/// `entity` carries the canonical Lua `M.meta` projection (name, version,
116/// description, category, docstring) via `#[serde(flatten)]` so the wire
117/// shape is identical to the pre-refactor flat-object layout. `source`
118/// is the typed package source; `card_count` / `best_card` are hub-side
119/// enrichments computed at index-build time.
120#[derive(Debug, Clone, Serialize, Deserialize)]
121pub(crate) struct IndexEntry {
122    #[serde(flatten)]
123    pub entity: PkgEntity,
124    /// How this package was obtained. Typed on write; legacy bare strings
125    /// in pre-migration `hub_index.json` deserialize via the serde shim
126    /// on `PackageSource` (see `service::source`).
127    #[serde(default)]
128    pub source: PackageSource,
129    #[serde(default)]
130    pub card_count: usize,
131    #[serde(default)]
132    pub best_card: Option<BestCard>,
133}
134
135/// Best card summary within a package.
136#[derive(Debug, Clone, Serialize, Deserialize)]
137pub(crate) struct BestCard {
138    pub card_id: String,
139    #[serde(default)]
140    pub model: String,
141    #[serde(default)]
142    pub pass_rate: f64,
143    #[serde(default)]
144    pub scenario: String,
145}
146
147/// Search result — index entry enriched with local install state.
148///
149/// `entity.docstring` is `skip_serializing` (via the `skip_docstring`
150/// custom serializer on the flattened struct) so the default serde output
151/// never exposes the docstring field — docstrings can be large and
152/// dominate payload size. The `hub_search` projection path re-attaches
153/// the docstring to the output object when the resolved field set
154/// contains `"docstring"`, via
155/// [`SearchResult::to_value_with_optional_docstring`].
156///
157/// `docstring_matched` is a query-time signal: it is `Some(true)` only
158/// when the query hit docstring and none of {name, description, category}.
159/// Otherwise (no query, or query hit any of the other fields) it is
160/// `None` and omitted from the output.
161///
162/// Because `#[serde(flatten)]` composes poorly with field-level
163/// `skip_serializing`, we carry the non-docstring part of `PkgEntity`
164/// via a custom `serialize_entity_without_docstring` path rather than a
165/// bare `#[serde(flatten)]`. The struct still holds a full `PkgEntity`
166/// internally for consistency with `IndexEntry`.
167#[derive(Debug, Clone, Serialize)]
168struct SearchResult {
169    #[serde(flatten, serialize_with = "serialize_entity_without_docstring")]
170    entity: PkgEntity,
171    /// Typed source (mirrors `IndexEntry.source`).
172    source: PackageSource,
173    installed: bool,
174    card_count: usize,
175    best_card: Option<BestCard>,
176    #[serde(skip_serializing_if = "Option::is_none")]
177    docstring_matched: Option<bool>,
178}
179
180/// Serialize a `PkgEntity` as a flat JSON object, intentionally dropping
181/// the `docstring` field so large docstrings do not dominate `hub_search`
182/// payloads. The projection path re-attaches docstring via
183/// [`SearchResult::to_value_with_optional_docstring`].
184fn serialize_entity_without_docstring<S>(entity: &PkgEntity, ser: S) -> Result<S::Ok, S::Error>
185where
186    S: serde::Serializer,
187{
188    use serde::ser::SerializeMap;
189    let mut map = ser.serialize_map(Some(4))?;
190    map.serialize_entry("name", &entity.name)?;
191    map.serialize_entry("version", &entity.version)?;
192    map.serialize_entry("description", &entity.description)?;
193    map.serialize_entry("category", &entity.category)?;
194    map.end()
195}
196
197impl SearchResult {
198    /// Serialize `self` to a JSON `Value`, optionally re-attaching
199    /// `docstring` to the resulting object.
200    ///
201    /// `skip_serializing` removes `docstring` from every serde output
202    /// path. When projection selects `docstring` as an output field, we
203    /// need to put it back — this helper bridges that gap by inserting
204    /// the field manually into the resulting `Value::Object`.
205    ///
206    /// Returns the original `Value` unchanged if serialization produced
207    /// a non-object (should not happen for `SearchResult`, but we stay
208    /// defensive because the downstream `project_fields` contract
209    /// tolerates non-objects).
210    fn to_value_with_optional_docstring(&self, include_docstring: bool) -> serde_json::Value {
211        let mut v = serde_json::to_value(self).unwrap_or(serde_json::Value::Null);
212        if include_docstring {
213            if let serde_json::Value::Object(ref mut map) = v {
214                let doc = self.entity.docstring.clone().unwrap_or_default();
215                map.insert("docstring".to_string(), serde_json::Value::String(doc));
216            }
217        }
218        v
219    }
220}
221
222// ─── Hub registries ───────────────────────────────────────────
223//
224// Persistent file (`~/.algocline/hub_registries.json`) that records
225// source URLs from `pkg_install` and `card_install`.  This is the
226// primary source for Hub index URL discovery — the manifest and
227// `AUTO_INSTALL_SOURCES` serve as fallback seeds.
228
229/// One entry in `hub_registries.json`.
230#[derive(Debug, Clone, Serialize, Deserialize)]
231pub(crate) struct RegistryEntry {
232    /// Original source URL (Git repo or local path).
233    pub source: String,
234    /// How it was registered: "pkg_install" or "card_install".
235    pub origin: String,
236    /// ISO 8601 timestamp of when the entry was added.
237    pub added_at: String,
238}
239
240/// Top-level registries file.
241#[derive(Debug, Clone, Serialize, Deserialize, Default)]
242pub(crate) struct HubRegistries {
243    pub registries: Vec<RegistryEntry>,
244}
245
246fn registries_path(app_dir: &AppDir) -> PathBuf {
247    app_dir.hub_registries_json()
248}
249
250/// Load registries from disk.
251///
252/// Returns `Ok(HubRegistries::default())` when the file does not yet exist —
253/// the file is created lazily on first `register_source` call. Returns `Err`
254/// when the file exists but cannot be read (I/O error) or parsed (corrupt
255/// JSON), so callers can surface the failure instead of silently degrading hub
256/// discovery.
257fn load_registries(app_dir: &AppDir) -> Result<HubRegistries, HubRegistriesError> {
258    let path = registries_path(app_dir);
259    if !path.exists() {
260        return Ok(HubRegistries::default());
261    }
262    let content = std::fs::read_to_string(&path).map_err(|e| {
263        HubRegistriesError::Parse(format!(
264            "failed to read hub_registries.json at {}: {e}",
265            path.display()
266        ))
267    })?;
268    serde_json::from_str::<HubRegistries>(&content).map_err(|e| {
269        HubRegistriesError::Parse(format!(
270            "failed to parse hub_registries.json at {}: {e}",
271            path.display()
272        ))
273    })
274}
275
276/// Register a source URL.  Deduplicates by normalized URL.
277///
278/// Returns `Ok(())` on success or when the input is skipped (empty /
279/// local path / already registered). Filesystem failures are returned
280/// as `Err(String)` so callers can surface them on the MCP wire
281/// response — the registry is best-effort relative to the `pkg_install`
282/// itself, but the caller still needs to know when it silently failed
283/// (otherwise hub discovery degrades without any signal).
284///
285/// Uses atomic write (tempfile + rename) to avoid partial writes if
286/// the process is interrupted. Read-modify-write is not locked across
287/// processes, but MCP servers are single-process so this is safe in
288/// practice.
289pub(crate) fn register_source(app_dir: &AppDir, source: &str, origin: &str) -> Result<(), String> {
290    let normalized = source.trim_end_matches('/').to_string();
291    if normalized.is_empty() {
292        return Ok(());
293    }
294    // Skip local paths — they can't host a remote index
295    if normalized.starts_with('/') || normalized.starts_with('.') {
296        return Ok(());
297    }
298
299    let path = registries_path(app_dir);
300    if let Some(parent) = path.parent() {
301        std::fs::create_dir_all(parent).map_err(|e| {
302            format!(
303                "failed to create hub registries dir {}: {e}",
304                parent.display()
305            )
306        })?;
307    }
308
309    // Re-read from disk right before write to minimize TOCTOU window.
310    // Parse failure is propagated — a corrupt registries file means we
311    // cannot safely read-modify-write without risking data loss.
312    let mut reg = load_registries(app_dir).map_err(|e| format!("cannot register source: {e}"))?;
313
314    // Already registered?
315    if reg
316        .registries
317        .iter()
318        .any(|e| e.source.trim_end_matches('/') == normalized)
319    {
320        return Ok(());
321    }
322
323    reg.registries.push(RegistryEntry {
324        source: normalized,
325        origin: origin.to_string(),
326        added_at: manifest::now_iso8601(),
327    });
328
329    // Atomic write: write to temp file, then rename
330    let json = serde_json::to_string_pretty(&reg)
331        .map_err(|e| format!("failed to serialize hub registries: {e}"))?;
332    let tmp_path = path.with_extension("json.tmp");
333    std::fs::write(&tmp_path, &json).map_err(|e| {
334        format!(
335            "failed to write hub registries tmp {}: {e}",
336            tmp_path.display()
337        )
338    })?;
339    std::fs::rename(&tmp_path, &path).map_err(|e| {
340        // Best-effort cleanup of the stale tmp file on rename failure.
341        let _ = std::fs::remove_file(&tmp_path);
342        format!(
343            "failed to atomically rename hub registries onto {}: {e}",
344            path.display()
345        )
346    })
347}
348
349// ─── Hub config ──────────────────────────────────────────────
350//
351// Optional `[hub]` section in `~/.algocline/config.toml`:
352//
353//   [hub]
354//   collection_url = "https://raw.githubusercontent.com/.../hub_index.json"
355//
356// When set, this is fetched as Tier 0 (the aggregated collection
357// index containing all known packages, including uninstalled ones).
358
359/// Read the `[hub].collection_url` from `~/.algocline/config.toml`.
360///
361/// Returns:
362/// - `Ok(Some(url))` — file exists, parses cleanly, `[hub].collection_url` present and non-empty.
363/// - `Ok(None)` — file absent (normal: config is optional) or `[hub].collection_url` not set.
364/// - `Err(msg)` — file exists but TOML parse fails (corruption); caller should surface as warning.
365fn collection_url_from_config(app_dir: &AppDir) -> Result<Option<String>, String> {
366    let path = app_dir.config_toml();
367    let content = match std::fs::read_to_string(&path) {
368        Ok(c) => c,
369        Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
370        Err(_) => return Ok(None), // permission errors etc. treated as absent
371    };
372    let doc: toml_edit::DocumentMut = content
373        .parse()
374        .map_err(|e| format!("config.toml parse: {e}"))?;
375    let url = match doc
376        .get("hub")
377        .and_then(|h| h.get("collection_url"))
378        .and_then(|v| v.as_str())
379    {
380        Some(s) => s.trim().to_string(),
381        None => return Ok(None),
382    };
383    if url.is_empty() {
384        Ok(None)
385    } else {
386        Ok(Some(url))
387    }
388}
389
390// ─── Index URL discovery ──────────────────────────────────────
391//
392// Derives remote index URLs from:
393//   0. Hub Collection URL (from config.toml) — aggregated index
394//   1. Hub registries (`hub_registries.json`) — primary source
395//   2. Unique `source` fields in the installed-packages manifest
396//   3. `AUTO_INSTALL_SOURCES` as fallback seeds (for first run)
397//
398// GitHub repos are transformed:
399//   https://github.com/{owner}/{repo}  →
400//   https://raw.githubusercontent.com/{owner}/{repo}/main/hub_index.json
401
402/// Convert a GitHub repo URL to a raw `hub_index.json` URL.
403/// Returns `None` for non-GitHub URLs (future: support other hosts).
404fn repo_to_index_url(repo_url: &str) -> Option<String> {
405    let trimmed = repo_url.trim_end_matches('/').trim_end_matches(".git");
406    if let Some(path) = trimmed.strip_prefix("https://github.com/") {
407        // path = "owner/repo"
408        let parts: Vec<&str> = path.splitn(3, '/').collect();
409        if parts.len() >= 2 {
410            return Some(format!(
411                "https://raw.githubusercontent.com/{}/{}/main/hub_index.json",
412                parts[0], parts[1]
413            ));
414        }
415    }
416    // Non-GitHub URL: assume it's already a direct index URL
417    if trimmed.ends_with(".json") {
418        Some(trimmed.to_string())
419    } else {
420        None
421    }
422}
423
424/// Collect unique index URLs from config + registries + manifest + bundled seeds.
425///
426/// Returns `Err` if the installed manifest cannot be read (corrupt JSON /
427/// permission denied). The function intentionally surfaces manifest-read
428/// failures rather than silently skipping — callers feed these URLs into
429/// hub resolution, and a partial URL set is indistinguishable from a
430/// corrupt manifest without the signal.
431///
432/// `warnings` collects non-fatal issues (e.g. config.toml TOML parse failure)
433/// that the caller should surface on the MCP wire response.
434fn discover_index_urls(
435    app_dir: &AppDir,
436    warnings: &mut Vec<String>,
437) -> Result<Vec<String>, String> {
438    let mut index_urls: Vec<String> = Vec::new();
439
440    // 0. From config.toml [hub].collection_url (Tier 0 — aggregated collection).
441    // Parse failures (corrupted config) are collected as warnings so the
442    // rest of discovery proceeds — the file is optional, but corruption
443    // is distinguishable from absence and must be surfaced to the caller.
444    match collection_url_from_config(app_dir) {
445        Ok(Some(url)) => index_urls.push(url),
446        Ok(None) => {}
447        Err(e) => warnings.push(format!("config.toml hub.collection_url: {e}")),
448    }
449
450    let mut repo_urls: HashSet<String> = HashSet::new();
451
452    // 1. From hub registries (primary). Parse failure is propagated so
453    // callers know the registry is degraded — a partial URL set from a
454    // corrupt file is indistinguishable from intentionally empty.
455    // `HubRegistriesError` is converted to `String` at the wire boundary
456    // (`discover_index_urls` still returns `Result<_, String>`).
457    let reg = load_registries(app_dir).map_err(|e| e.to_string())?;
458    for entry in &reg.registries {
459        let normalized = entry.source.trim_end_matches('/').to_string();
460        if !normalized.is_empty() {
461            repo_urls.insert(normalized);
462        }
463    }
464
465    // 2. From manifest (catch sources registered before hub_registries existed).
466    // Only Git-variant sources can host a remote hub_index.json; other variants
467    // (Path / Installed / Bundled / Unknown) are skipped by `git_url()` returning None.
468    let m = manifest::load_manifest(app_dir)?;
469    for entry in m.packages.values() {
470        if let Some(url) = entry.source.git_url() {
471            let normalized = url.trim_end_matches('/').to_string();
472            if !normalized.is_empty() {
473                repo_urls.insert(normalized);
474            }
475        }
476    }
477
478    // 3. Fallback: bundled sources (ensures at least these are checked)
479    for url in AUTO_INSTALL_SOURCES {
480        repo_urls.insert(url.to_string());
481    }
482
483    // 4. Transform repo URLs → index URLs, dedup against Tier 0
484    let existing: HashSet<String> = index_urls.iter().cloned().collect();
485    let mut derived: Vec<String> = repo_urls
486        .iter()
487        .filter_map(|url| repo_to_index_url(url))
488        .filter(|url| !existing.contains(url))
489        .collect();
490    derived.sort();
491    derived.dedup();
492    index_urls.extend(derived);
493
494    Ok(index_urls)
495}
496
497// ─── Per-source cache ─────────────────────────────────────────
498//
499// Each remote index is cached separately at
500// `~/.algocline/hub_cache/{hash}.json` where hash is derived from
501// the index URL. This avoids mixing data from different registries
502// and allows per-source TTL validation.
503
504fn cache_dir(app_dir: &AppDir) -> PathBuf {
505    app_dir.hub_cache_dir()
506}
507
508fn cache_key(url: &str) -> String {
509    // Simple hash: use the URL bytes to produce a stable hex string.
510    // Avoids pulling in a hash crate — good enough for cache file naming.
511    let mut h: u64 = 0xcbf2_9ce4_8422_2325; // FNV-1a offset basis
512    for b in url.as_bytes() {
513        h ^= *b as u64;
514        h = h.wrapping_mul(0x0100_0000_01b3); // FNV prime
515    }
516    format!("{h:016x}")
517}
518
519/// Load cached remote index for a specific URL if fresh (within TTL).
520///
521/// Returns:
522/// - `Ok(Some(index))` — cache hit: file exists, within TTL, parses cleanly.
523/// - `Ok(None)` — cache miss: file absent, expired, or metadata unreadable (treat as miss).
524/// - `Err(msg)` — file exists and is within TTL but JSON parse fails (corruption);
525///   caller should surface as warning and fall back to a network fetch.
526fn load_cached(app_dir: &AppDir, url: &str) -> Result<Option<HubIndex>, String> {
527    let dir = cache_dir(app_dir);
528    let path = dir.join(format!("{}.json", cache_key(url)));
529    if !path.exists() {
530        return Ok(None);
531    }
532    // Metadata failure (e.g. race condition) → treat as cache miss, not corruption.
533    let metadata = match std::fs::metadata(&path) {
534        Ok(m) => m,
535        Err(_) => return Ok(None),
536    };
537    let age = match metadata.modified().ok().and_then(|t| t.elapsed().ok()) {
538        Some(a) => a,
539        None => return Ok(None),
540    };
541    if age.as_secs() > CACHE_TTL_SECS {
542        return Ok(None);
543    }
544    // File exists and is within TTL — read and parse. A parse failure here
545    // indicates an on-disk corruption (truncated write, etc.) that is
546    // distinguishable from a cache miss and must be surfaced to the caller.
547    let content = std::fs::read_to_string(&path)
548        .map_err(|e| format!("hub cache read {}: {e}", path.display()))?;
549    serde_json::from_str(&content)
550        .map(Some)
551        .map_err(|e| format!("hub cache parse {}: {e}", path.display()))
552}
553
554/// Save remote index to per-source cache file.
555///
556/// Returns `Ok(())` on success. Cache write failures are returned as
557/// `Err(String)`; the caller (`fetch_one`) carries them out of band so
558/// hub fetch still completes (the index is in memory) but the warning
559/// surfaces to the MCP wire response via the existing `warnings` channel.
560fn save_cached(app_dir: &AppDir, url: &str, index: &HubIndex) -> Result<(), String> {
561    let dir = cache_dir(app_dir);
562    std::fs::create_dir_all(&dir)
563        .map_err(|e| format!("failed to create hub cache dir {}: {e}", dir.display()))?;
564    let path = dir.join(format!("{}.json", cache_key(url)));
565    let json = serde_json::to_string_pretty(index)
566        .map_err(|e| format!("failed to serialize hub cache: {e}"))?;
567    std::fs::write(&path, json)
568        .map_err(|e| format!("failed to write hub cache {}: {e}", path.display()))
569}
570
571// ─── Remote fetch ──────────────────────────────────────────────
572
573/// Fetch a single remote index by URL, using per-source cache.
574///
575/// Returns the index plus an optional cache-related warning. The warning
576/// is non-None when either:
577/// - The network fetch succeeded but persisting the cache to disk failed.
578/// - The cache file was present and within TTL but failed to parse
579///   (corruption); in that case the function falls back to a network
580///   fetch and includes the parse-failure in the warning so the operator
581///   can investigate the on-disk state.
582fn fetch_one(app_dir: &AppDir, url: &str) -> Result<(HubIndex, Option<String>), String> {
583    // Distinguish cache corruption (Err) from cache miss (Ok(None)).
584    match load_cached(app_dir, url) {
585        Ok(Some(cached)) => return Ok((cached, None)),
586        Ok(None) => {} // cache miss — proceed to network fetch
587        Err(e) => {
588            // Cache file is corrupt. Fall through to network fetch and
589            // carry the corruption warning so the caller can surface it.
590            // We don't return Err here because the network path may still succeed.
591            let warn = format!("hub cache corrupted for {url}: {e}; falling back to network");
592            // Attempt network fetch; on success, attach the cache-corruption warning.
593            return fetch_one_from_network(app_dir, url)
594                .map(|(idx, save_warn)| {
595                    // Prefer the corruption warning; save_warn is secondary.
596                    let combined = Some(match save_warn {
597                        Some(sw) => format!("{warn}; {sw}"),
598                        None => warn.clone(),
599                    });
600                    (idx, combined)
601                })
602                .map_err(|fetch_err| format!("{warn}; network fetch also failed: {fetch_err}"));
603        }
604    }
605
606    fetch_one_from_network(app_dir, url)
607}
608
609/// Network-only path for fetching a remote index (no cache read).
610///
611/// On success returns `(index, Option<cache_write_warning>)`.
612fn fetch_one_from_network(
613    app_dir: &AppDir,
614    url: &str,
615) -> Result<(HubIndex, Option<String>), String> {
616    let agent = ureq::Agent::new_with_config(
617        ureq::config::Config::builder()
618            .timeout_global(Some(HTTP_TIMEOUT))
619            .build(),
620    );
621    let body: String = agent
622        .get(url)
623        .call()
624        .map_err(|e| format!("Failed to fetch {url}: {e}"))?
625        .body_mut()
626        .read_to_string()
627        .map_err(|e| format!("Failed to read response from {url}: {e}"))?;
628
629    let index: HubIndex = serde_json::from_str(&body)
630        .map_err(|e| format!("Failed to parse index from {url}: {e}"))?;
631
632    let cache_warning = save_cached(app_dir, url, &index)
633        .err()
634        .map(|e| format!("hub cache write for {url}: {e}"));
635    Ok((index, cache_warning))
636}
637
638/// Fetch all discovered remote indices and merge into one.
639/// Falls back gracefully: failed sources are skipped with warnings.
640fn fetch_remote_indices(app_dir: &AppDir) -> Result<(HubIndex, Vec<String>), String> {
641    let mut warnings: Vec<String> = Vec::new();
642    let urls = discover_index_urls(app_dir, &mut warnings)?;
643    let mut all_packages: Vec<IndexEntry> = Vec::new();
644    let mut seen_names: HashSet<String> = HashSet::new();
645
646    for url in &urls {
647        match fetch_one(app_dir, url) {
648            Ok((index, cache_warning)) => {
649                for entry in index.packages {
650                    if seen_names.insert(entry.entity.name.clone()) {
651                        all_packages.push(entry);
652                    }
653                    // If duplicate name across sources, first wins
654                }
655                if let Some(w) = cache_warning {
656                    warnings.push(w);
657                }
658            }
659            Err(e) => {
660                warnings.push(e);
661            }
662        }
663    }
664
665    if all_packages.is_empty() && !warnings.is_empty() {
666        warnings.insert(
667            0,
668            "all remote indices unavailable, showing local packages only".to_string(),
669        );
670    }
671
672    let merged = HubIndex {
673        schema_version: "hub_index/v0".into(),
674        updated_at: String::new(),
675        packages: all_packages,
676    };
677    Ok((merged, warnings))
678}
679
680// ─── Local state ───────────────────────────────────────────────
681
682/// Build a set of locally installed package names from `installed.json`
683/// and the `~/.algocline/packages/` directory.
684fn installed_packages(app_dir: &AppDir) -> Result<HashMap<String, Option<String>>, String> {
685    let mut map = HashMap::new();
686
687    // From manifest (has version info)
688    let m = manifest::load_manifest(app_dir)?;
689    for (name, entry) in &m.packages {
690        map.insert(name.clone(), entry.version.clone());
691    }
692
693    // Also scan packages/ dir in case manifest is stale
694    let pkg_dir = app_dir.packages_dir();
695    if let Ok(entries) = std::fs::read_dir(&pkg_dir) {
696        for entry in entries.flatten() {
697            if entry.path().is_dir() {
698                if let Some(name) = entry.file_name().to_str() {
699                    map.entry(name.to_string()).or_insert(None);
700                }
701            }
702        }
703    }
704
705    Ok(map)
706}
707
708/// Count local cards per package from `{app_dir}/cards/{pkg}/`.
709fn local_card_counts(app_dir: &AppDir) -> HashMap<String, usize> {
710    let mut map = HashMap::new();
711    let cards_dir = app_dir.cards_dir();
712    let entries = match std::fs::read_dir(&cards_dir) {
713        Ok(e) => e,
714        Err(_) => return map,
715    };
716    for entry in entries.flatten() {
717        if !entry.path().is_dir() {
718            continue;
719        }
720        let pkg = match entry.file_name().to_str() {
721            Some(n) => n.to_string(),
722            None => continue,
723        };
724        let count = std::fs::read_dir(entry.path())
725            .map(|es| {
726                es.flatten()
727                    .filter(|e| e.path().extension().is_some_and(|ext| ext == "toml"))
728                    .count()
729            })
730            .unwrap_or(0);
731        if count > 0 {
732            map.insert(pkg, count);
733        }
734    }
735    map
736}
737
738/// Count eval results for a specific package by scanning `{app_dir}/evals/`.
739///
740/// Reads only `.meta.json` files (lightweight) to check the strategy field.
741/// Falls back to reading full eval JSON if meta is missing.
742///
743/// `warnings` receives per-file corruption messages (read or parse failures).
744/// I/O errors on the directory itself return 0 silently (evals dir absent is
745/// a legitimate "no evals yet" state). Per-file errors that indicate corruption
746/// (file exists but is unreadable or unparseable) are pushed to `warnings` so
747/// the caller can surface them on the MCP wire response.
748fn count_evals_for_pkg(app_dir: &AppDir, pkg: &str, warnings: &mut Vec<String>) -> usize {
749    let evals_dir = app_dir.evals_dir();
750    let entries = match std::fs::read_dir(&evals_dir) {
751        Ok(e) => e,
752        Err(_) => return 0,
753    };
754
755    // Collect all filenames first so ordering doesn't matter.
756    // We track stems that have a .meta.json to avoid reading the full eval JSON.
757    let mut meta_stems: HashSet<String> = HashSet::new();
758    let mut meta_matches: usize = 0;
759    let mut non_meta_paths: Vec<(PathBuf, String)> = Vec::new(); // (path, stem)
760
761    for entry in entries.flatten() {
762        let path = entry.path();
763        let name = match path.file_name().and_then(|n| n.to_str()) {
764            Some(n) => n.to_string(),
765            None => continue,
766        };
767
768        if name.ends_with(".meta.json") {
769            let stem = name.trim_end_matches(".meta.json").to_string();
770            meta_stems.insert(stem.clone());
771            // Distinguish I/O failure from parse failure so corruption is visible.
772            match std::fs::read_to_string(&path) {
773                Ok(content) => match serde_json::from_str::<serde_json::Value>(&content) {
774                    Ok(val) => {
775                        if val.get("strategy").and_then(|s| s.as_str()) == Some(pkg) {
776                            meta_matches += 1;
777                        }
778                    }
779                    Err(e) => warnings.push(format!("eval meta parse {}: {e}", path.display())),
780                },
781                Err(e) => warnings.push(format!("eval meta read {}: {e}", path.display())),
782            }
783            continue;
784        }
785
786        // Skip non-json or comparison files
787        if !name.ends_with(".json") || name.starts_with("compare_") {
788            continue;
789        }
790
791        let stem = path
792            .file_stem()
793            .and_then(|s| s.to_str())
794            .unwrap_or("")
795            .to_string();
796        non_meta_paths.push((path, stem));
797    }
798
799    // Only read full eval JSON for entries without a .meta.json.
800    // Distinguish I/O and parse failures; both are surfaced as warnings.
801    let mut fallback_matches: usize = 0;
802    for (path, stem) in &non_meta_paths {
803        if meta_stems.contains(stem) {
804            continue;
805        }
806        match std::fs::read_to_string(path) {
807            Ok(c) => match serde_json::from_str::<serde_json::Value>(&c) {
808                Ok(v) => {
809                    if v.get("strategy").and_then(|s| s.as_str()) == Some(pkg) {
810                        fallback_matches += 1;
811                    }
812                }
813                Err(e) => warnings.push(format!("eval result parse {}: {e}", path.display())),
814            },
815            Err(e) => warnings.push(format!("eval result read {}: {e}", path.display())),
816        }
817    }
818
819    meta_matches + fallback_matches
820}
821
822// ─── Merge ─────────────────────────────────────────────────────
823
824/// Merge remote index with local install state.
825///
826/// When a package is installed locally and the remote index lacks a
827/// docstring (pre-v0.21 indices), the docstring is extracted from the
828/// local `init.lua` so that full-text search works immediately.
829fn merge(app_dir: &AppDir, remote: &HubIndex) -> Result<Vec<SearchResult>, String> {
830    let installed = installed_packages(app_dir)?;
831    let card_counts = local_card_counts(app_dir);
832    let pkg_dir: Option<PathBuf> = Some(app_dir.packages_dir());
833
834    let mut seen: HashSet<String> = HashSet::new();
835    let mut results: Vec<SearchResult> = Vec::new();
836
837    for entry in &remote.packages {
838        let pkg_name = &entry.entity.name;
839        let is_installed = installed.contains_key(pkg_name);
840        let local_cards = card_counts.get(pkg_name).copied().unwrap_or(0);
841
842        // Supplement empty docstring from local init.lua when installed.
843        // Re-parse via `PkgEntity` so the supplementation path stays
844        // consistent with `build_index`.
845        let docstring = if entry.entity.docstring.as_deref().unwrap_or("").is_empty()
846            && is_installed
847        {
848            pkg_dir
849                .as_ref()
850                .and_then(|d| PkgEntity::parse_from_init_lua(&d.join(pkg_name).join("init.lua")))
851                .and_then(|e| e.docstring)
852        } else {
853            entry.entity.docstring.clone()
854        };
855
856        seen.insert(pkg_name.clone());
857        let mut merged_entity = entry.entity.clone();
858        merged_entity.docstring = docstring;
859        results.push(SearchResult {
860            entity: merged_entity,
861            source: entry.source.clone(),
862            installed: is_installed,
863            card_count: if is_installed && local_cards > entry.card_count {
864                local_cards
865            } else {
866                entry.card_count
867            },
868            best_card: entry.best_card.clone(),
869            docstring_matched: None,
870        });
871    }
872
873    // Add local-only packages (not in remote index).
874    for (name, version) in &installed {
875        if seen.contains(name) {
876            continue;
877        }
878        // Pull full `PkgEntity` from local init.lua when available (keeps the
879        // wire shape consistent with remote entries). When the package does
880        // not parse as a `PkgEntity` (missing `M.meta.name`), fall back to
881        // a minimal entity with just the directory name and the manifest
882        // version — the entry still appears in local-only listings, but the
883        // richer projection fields are simply absent.
884        let parsed_entity = pkg_dir
885            .as_ref()
886            .and_then(|d| PkgEntity::parse_from_init_lua(&d.join(name).join("init.lua")));
887        let entity = parsed_entity.unwrap_or(PkgEntity {
888            name: name.clone(),
889            version: version.clone(),
890            description: None,
891            category: None,
892            docstring: None,
893        });
894        results.push(SearchResult {
895            entity,
896            source: PackageSource::Unknown,
897            installed: true,
898            card_count: card_counts.get(name).copied().unwrap_or(0),
899            best_card: None,
900            docstring_matched: None,
901        });
902    }
903
904    Ok(results)
905}
906
907// ─── Search (filtering) ───────────────────────────────────────
908
909fn matches_query(result: &SearchResult, query: &str) -> bool {
910    let q = query.to_lowercase();
911    let pkg = &result.entity;
912    let empty = String::new();
913    pkg.name.to_lowercase().contains(&q)
914        || pkg
915            .description
916            .as_ref()
917            .unwrap_or(&empty)
918            .to_lowercase()
919            .contains(&q)
920        || pkg
921            .category
922            .as_ref()
923            .unwrap_or(&empty)
924            .to_lowercase()
925            .contains(&q)
926        || pkg
927            .docstring
928            .as_ref()
929            .unwrap_or(&empty)
930            .to_lowercase()
931            .contains(&q)
932}
933
934// ─── Index generation (reindex) ───────────────────────────────
935//
936// The non-Lua-VM parser that used to live here
937// (`parse_meta_from_init_lua` / `extract_docstring`) has moved into
938// `algocline_core::PkgEntity::parse_from_init_lua`, where it is shared
939// with the manifest / lockfile wire format. The parsing tests migrated
940// with it; `hub.rs` now just consumes the typed `PkgEntity` projection.
941
942/// Build a hub index by scanning a packages directory.
943///
944/// When `source_dir` is provided, scans that directory directly
945/// (for generating an index from a repo checkout).  Metadata comes
946/// only from `init.lua` — no manifest lookup, no card counts.
947///
948/// When `source_dir` is `None`, scans `~/.algocline/packages/` and
949/// enriches entries with manifest source and local card counts.
950fn build_index(app_dir: &AppDir, source_dir: Option<&std::path::Path>) -> Result<HubIndex, String> {
951    let empty = || HubIndex {
952        schema_version: "hub_index/v0".into(),
953        updated_at: super::manifest::now_iso8601(),
954        packages: Vec::new(),
955    };
956
957    let pkg_dir = match source_dir {
958        Some(d) => d.to_path_buf(),
959        None => app_dir.packages_dir(),
960    };
961
962    let use_local_state = source_dir.is_none();
963    let card_counts = if use_local_state {
964        local_card_counts(app_dir)
965    } else {
966        HashMap::new()
967    };
968    // Manifest read errors surface as `Err` rather than degrading to an
969    // empty manifest — when building the local hub index, a corrupt
970    // `installed.json` silently turning all package sources into
971    // `PackageSource::Unknown` would be indistinguishable from the
972    // legitimate "no source recorded" state, and would ship into
973    // generated `hub_index.json` files verbatim.
974    let manifest = if use_local_state {
975        manifest::load_manifest(app_dir)?
976    } else {
977        manifest::Manifest::default()
978    };
979
980    let mut entries = Vec::new();
981
982    // Missing / unreadable `pkg_dir` is a legitimate "no packages yet"
983    // state on a fresh install — distinct from manifest corruption
984    // above, and safe to surface as an empty index.
985    let dir_entries = match std::fs::read_dir(&pkg_dir) {
986        Ok(e) => e,
987        Err(_) => return Ok(empty()),
988    };
989
990    for entry in dir_entries.flatten() {
991        if !entry.path().is_dir() {
992            continue;
993        }
994        let dir_name = match entry.file_name().to_str() {
995            Some(n) if !n.starts_with('.') && !n.starts_with('_') => n.to_string(),
996            _ => continue,
997        };
998
999        let init_lua = entry.path().join("init.lua");
1000        if !init_lua.exists() {
1001            continue;
1002        }
1003
1004        // Silent-exclude gate: `PkgEntity::parse_from_init_lua` returns `None`
1005        // when `M.meta` is absent or `M.meta.name` is empty. Directories that
1006        // happen to contain an `init.lua` but aren't algocline packages
1007        // (e.g. `alc_shapes/`, a type DSL library) are dropped from the index
1008        // rather than falling through with a placeholder name — that would
1009        // pollute hub_search.
1010        let Some(entity) = PkgEntity::parse_from_init_lua(&init_lua) else {
1011            continue;
1012        };
1013
1014        // Use manifest source only for local-state mode. When the manifest
1015        // has no record for this directory, default to `PackageSource::Unknown`
1016        // (via `Default`) — hub consumers see it as "source not recorded".
1017        let source = manifest
1018            .packages
1019            .get(&dir_name)
1020            .map(|e| e.source.clone())
1021            .unwrap_or_default();
1022
1023        entries.push(IndexEntry {
1024            entity,
1025            source,
1026            card_count: card_counts.get(&dir_name).copied().unwrap_or(0),
1027            best_card: None,
1028        });
1029    }
1030
1031    entries.sort_by(|a, b| a.entity.name.cmp(&b.entity.name));
1032
1033    Ok(HubIndex {
1034        schema_version: "hub_index/v0".into(),
1035        updated_at: super::manifest::now_iso8601(),
1036        packages: entries,
1037    })
1038}
1039
1040// ─── Public API ────────────────────────────────────────────────
1041
1042impl AppService {
1043    /// Generate a hub index from a packages directory.
1044    ///
1045    /// When `source_dir` is provided, scans that directory (e.g. a
1046    /// repo checkout) — pure metadata extraction, no manifest or card
1047    /// data mixed in.  When omitted, scans `~/.algocline/packages/`.
1048    ///
1049    /// Writes the index to `output_path` (for CI / publishing).
1050    /// Does NOT touch the remote search cache.
1051    pub fn hub_reindex(
1052        &self,
1053        output_path: Option<&str>,
1054        source_dir: Option<&str>,
1055    ) -> Result<String, String> {
1056        let src = source_dir.map(std::path::Path::new);
1057        if let Some(d) = src {
1058            if !d.is_dir() {
1059                return Err(format!("source_dir '{}' is not a directory", d.display()));
1060            }
1061        }
1062        let app_dir = self.log_config.app_dir();
1063        let index = build_index(&app_dir, src)?;
1064
1065        let written_path = if let Some(path) = output_path {
1066            let json = serde_json::to_string_pretty(&index)
1067                .map_err(|e| format!("Failed to serialize index: {e}"))?;
1068            std::fs::write(path, &json)
1069                .map_err(|e| format!("Failed to write index to {path}: {e}"))?;
1070            Some(path.to_string())
1071        } else {
1072            None
1073        };
1074
1075        let response = serde_json::json!({
1076            "package_count": index.packages.len(),
1077            "updated_at": index.updated_at,
1078            "output_path": written_path,
1079            "source_dir": source_dir,
1080        });
1081        Ok(response.to_string())
1082    }
1083
1084    /// Show detailed information for a single package.
1085    ///
1086    /// Aggregates package metadata (from index or local `init.lua`),
1087    /// all Cards, aliases, and eval stats into one response.
1088    pub fn hub_info(&self, pkg: &str) -> Result<String, String> {
1089        use algocline_engine::card;
1090
1091        // Guard against path traversal
1092        if pkg.contains("..") || pkg.contains('/') || pkg.contains('\\') {
1093            return Err(format!("Invalid package name: '{pkg}'"));
1094        }
1095
1096        // Package metadata: try remote index first, fall back to local
1097        let app_dir = self.log_config.app_dir();
1098        let installed = installed_packages(&app_dir)?;
1099        let is_installed = installed.contains_key(pkg);
1100
1101        // Resolve package metadata: try remote index first, fall back to
1102        // local init.lua. `version` / `description` / `category` are modelled
1103        // as `Option<String>` at the `PkgEntity` layer; at this API surface
1104        // we flatten `None` to empty string so the wire shape (non-null
1105        // JSON string fields) stays unchanged for existing consumers.
1106        let (version, description, category, source) = {
1107            let (remote, _) = fetch_remote_indices(&app_dir)?;
1108            if let Some(entry) = remote.packages.iter().find(|e| e.entity.name == pkg) {
1109                (
1110                    entry.entity.version.clone().unwrap_or_default(),
1111                    entry.entity.description.clone().unwrap_or_default(),
1112                    entry.entity.category.clone().unwrap_or_default(),
1113                    entry.source.clone(),
1114                )
1115            } else if is_installed {
1116                // Fall back to local init.lua parse via `PkgEntity`. When
1117                // the file is not a valid package (no `M.meta.name`), we
1118                // degrade gracefully by returning the manifest-recorded
1119                // version and empty string fields — mirroring the pre-typed
1120                // behaviour.
1121                let init_lua = app_dir.packages_dir().join(pkg).join("init.lua");
1122                let entity = PkgEntity::parse_from_init_lua(&init_lua);
1123                let manifest_source = manifest::load_manifest(&app_dir)?
1124                    .packages
1125                    .get(pkg)
1126                    .map(|e| e.source.clone())
1127                    .unwrap_or_default();
1128                match entity {
1129                    Some(e) => (
1130                        e.version.unwrap_or_default(),
1131                        e.description.unwrap_or_default(),
1132                        e.category.unwrap_or_default(),
1133                        manifest_source,
1134                    ),
1135                    None => (
1136                        installed.get(pkg).cloned().flatten().unwrap_or_default(),
1137                        String::new(),
1138                        String::new(),
1139                        manifest_source,
1140                    ),
1141                }
1142            } else {
1143                return Err(format!(
1144                    "Package '{pkg}' not found in remote indices or locally installed packages"
1145                ));
1146            }
1147        };
1148
1149        // Collect warnings additively; surfaced in response JSON so MCP callers
1150        // (Claude Code UI) observe degraded data instead of silent loss.
1151        // See CLAUDE.md §Service 層の Error 伝播規律 — tracing alone is not enough.
1152        let mut warnings: Vec<String> = Vec::new();
1153
1154        // Cards for this package (single call, reused for stats)
1155        let card_rows = match self.card_store.list(Some(pkg)) {
1156            Ok(rows) => rows,
1157            Err(e) => {
1158                let msg = format!("card store list for '{pkg}': {e}");
1159                tracing::warn!("{}", msg);
1160                warnings.push(msg);
1161                vec![]
1162            }
1163        };
1164        let cards_json = card::summaries_to_json(&card_rows);
1165
1166        // Aliases for this package
1167        let aliases_json = match self.card_store.alias_list(Some(pkg)) {
1168            Ok(rows) => card::aliases_to_json(&rows),
1169            Err(e) => {
1170                let msg = format!("card store alias_list for '{pkg}': {e}");
1171                tracing::warn!("{}", msg);
1172                warnings.push(msg);
1173                serde_json::json!([])
1174            }
1175        };
1176
1177        // Stats: card count, best pass_rate, eval count
1178        let card_count = card_rows.len();
1179        let best_pass_rate = card_rows
1180            .iter()
1181            .filter_map(|c| c.pass_rate)
1182            .fold(f64::NEG_INFINITY, f64::max);
1183        let best_pass_rate = if best_pass_rate.is_finite() {
1184            Some(best_pass_rate)
1185        } else {
1186            None
1187        };
1188
1189        // Eval count from evals directory; corruption warnings surfaced additively.
1190        let eval_count = count_evals_for_pkg(&app_dir, pkg, &mut warnings);
1191
1192        let mut response = serde_json::json!({
1193            "pkg": {
1194                "name": pkg,
1195                "version": version,
1196                "description": description,
1197                "category": category,
1198                "source": source,
1199                "installed": is_installed,
1200            },
1201            "cards": cards_json,
1202            "aliases": aliases_json,
1203            "stats": {
1204                "card_count": card_count,
1205                "eval_count": eval_count,
1206                "best_pass_rate": best_pass_rate,
1207            },
1208        });
1209        if !warnings.is_empty() {
1210            response["warnings"] = serde_json::json!(warnings);
1211        }
1212        Ok(response.to_string())
1213    }
1214
1215    /// Search packages across remote indices + local state.
1216    ///
1217    /// Index URLs are discovered from hub registries, manifest sources,
1218    /// and `AUTO_INSTALL_SOURCES`. Each source is cached independently.
1219    ///
1220    /// ## List-tool options (`opts`)
1221    ///
1222    /// The `opts` parameter carries the list-tool primitives
1223    /// (`limit / sort / filter / fields / verbose`) shared with other
1224    /// list-style MCP tools. Defaults:
1225    ///
1226    /// - `limit` — 50 when `None`. `Some(0)` means **no limit** (return
1227    ///   all matching entries — empty-means-all idiom).
1228    /// - `sort` — `"-installed,name"` when `None` (installed first, then
1229    ///   ascending by name).
1230    /// - `filter` — no additional filter. Legacy `category` /
1231    ///   `installed_only` parameters are merged into the filter map when
1232    ///   `filter` does not already contain those keys (explicit
1233    ///   `filter` wins on conflict).
1234    /// - `fields` / `verbose` — projection is applied to every entry in
1235    ///   the `results` array (see
1236    ///   [`super::list_opts::resolve_fields`]). Top-level keys
1237    ///   (`total`, `sources`, `warnings`) are never projected away.
1238    ///
1239    /// ## docstring handling
1240    ///
1241    /// [`SearchResult::docstring`] is `skip_serializing`, so it is
1242    /// absent from the default serialized view. When the resolved
1243    /// projection contains `"docstring"`, it is re-injected into the
1244    /// per-entry JSON via
1245    /// [`SearchResult::to_value_with_optional_docstring`].
1246    pub(crate) fn hub_search(
1247        &self,
1248        query: Option<&str>,
1249        category: Option<&str>,
1250        installed_only: Option<bool>,
1251        opts: ListOpts,
1252    ) -> Result<String, String> {
1253        let app_dir = self.log_config.app_dir();
1254        let (remote, warnings) = fetch_remote_indices(&app_dir)?;
1255        let mut results = merge(&app_dir, &remote)?;
1256
1257        // Filter by query (internal signal covers name/description/
1258        // category/docstring — `matches_query` unchanged).
1259        let query_lower = query.filter(|q| !q.is_empty()).map(|q| q.to_lowercase());
1260        if let Some(ref ql) = query_lower {
1261            results.retain(|r| matches_query(r, ql));
1262        }
1263
1264        // Compute docstring_matched per remaining hit: Some(true) only
1265        // when the query matched docstring and none of {name,
1266        // description, category}; otherwise None.
1267        if let Some(ref ql) = query_lower {
1268            for r in &mut results {
1269                let empty = String::new();
1270                let pkg = &r.entity;
1271                let other_hit = pkg.name.to_lowercase().contains(ql)
1272                    || pkg
1273                        .description
1274                        .as_ref()
1275                        .unwrap_or(&empty)
1276                        .to_lowercase()
1277                        .contains(ql)
1278                    || pkg
1279                        .category
1280                        .as_ref()
1281                        .unwrap_or(&empty)
1282                        .to_lowercase()
1283                        .contains(ql);
1284                let doc_hit = pkg
1285                    .docstring
1286                    .as_ref()
1287                    .unwrap_or(&empty)
1288                    .to_lowercase()
1289                    .contains(ql);
1290                r.docstring_matched = if !other_hit && doc_hit {
1291                    Some(true)
1292                } else {
1293                    None
1294                };
1295            }
1296        }
1297
1298        // Build the effective filter map: start from explicit `opts.filter`,
1299        // then fold legacy `category` / `installed_only` in only if the
1300        // corresponding key is not already set (explicit filter wins).
1301        let mut filter_map: std::collections::HashMap<String, serde_json::Value> =
1302            opts.filter.unwrap_or_default();
1303        if let Some(cat) = category {
1304            filter_map
1305                .entry("category".to_string())
1306                .or_insert_with(|| serde_json::Value::String(cat.to_string()));
1307        }
1308        if let Some(only) = installed_only {
1309            // Preserve prior semantic: `installed_only=Some(false)` was a
1310            // no-op (it did not force `installed=false`). Only fold when
1311            // explicitly true.
1312            if only {
1313                filter_map
1314                    .entry("installed".to_string())
1315                    .or_insert(serde_json::Value::Bool(true));
1316            }
1317        }
1318
1319        // Resolve sort keys up-front so an invalid sort string errors out
1320        // before we touch results.
1321        let sort_str = opts.sort.as_deref().unwrap_or("-installed,name");
1322        let sort_keys = parse_sort(sort_str)?;
1323
1324        // Resolve projection fields; this also rejects unknown `verbose`
1325        // values before any heavy work.
1326        let fields = resolve_fields(
1327            opts.verbose.as_deref(),
1328            opts.fields.as_deref(),
1329            HUB_SEARCH_SUMMARY,
1330            HUB_SEARCH_FULL,
1331        )?;
1332        let include_docstring = fields.iter().any(|f| f == "docstring");
1333
1334        // Serialize each result to a Value (docstring optionally attached)
1335        // so filter/sort/projection work uniformly on JSON values.
1336        let mut items: Vec<serde_json::Value> = results
1337            .iter()
1338            .map(|r| r.to_value_with_optional_docstring(include_docstring))
1339            .collect();
1340
1341        // Filter AFTER serialization so filter keys can reference
1342        // projection-level shape (e.g. `category`, `installed`).
1343        if !filter_map.is_empty() {
1344            items.retain(|v| matches_filter(v, &filter_map));
1345        }
1346
1347        // Sort.
1348        apply_sort_by_value(&mut items, &sort_keys);
1349
1350        // Limit. `limit = Some(0)` means "no limit" (return all results)
1351        // — mirrors the `empty=all & some=filter` idiom used across the
1352        // list-tool contract. `None` falls back to the default cap (50).
1353        let total = items.len();
1354        let limit = opts.limit.unwrap_or(50);
1355        if limit > 0 {
1356            items.truncate(limit);
1357        }
1358
1359        // Projection (after truncation — unselected fields are stripped
1360        // from the kept entries only).
1361        let projected: Vec<serde_json::Value> = items
1362            .into_iter()
1363            .map(|v| project_fields(v, &fields))
1364            .collect();
1365
1366        // Collect discovered sources for transparency.
1367        // Warnings from this call (e.g. config.toml parse failure) are
1368        // already present in `warnings` from `fetch_remote_indices` above;
1369        // use a throwaway buffer here to avoid duplicating them.
1370        let mut _src_warnings: Vec<String> = Vec::new();
1371        let sources = discover_index_urls(&app_dir, &mut _src_warnings)?;
1372
1373        let mut json = serde_json::json!({
1374            "results": projected,
1375            "total": total,
1376            "sources": sources,
1377        });
1378        if !warnings.is_empty() {
1379            json["warnings"] = serde_json::json!(warnings);
1380        }
1381        Ok(json.to_string())
1382    }
1383}
1384
1385#[cfg(test)]
1386mod tests {
1387    use super::*;
1388
1389    #[test]
1390    fn repo_to_index_url_github() {
1391        assert_eq!(
1392            repo_to_index_url("https://github.com/ynishi/algocline-bundled-packages"),
1393            Some(
1394                "https://raw.githubusercontent.com/ynishi/algocline-bundled-packages/main/hub_index.json"
1395                    .to_string()
1396            )
1397        );
1398    }
1399
1400    #[test]
1401    fn repo_to_index_url_github_trailing_slash() {
1402        assert_eq!(
1403            repo_to_index_url("https://github.com/user/repo/"),
1404            Some("https://raw.githubusercontent.com/user/repo/main/hub_index.json".to_string())
1405        );
1406    }
1407
1408    #[test]
1409    fn repo_to_index_url_github_dot_git() {
1410        assert_eq!(
1411            repo_to_index_url("https://github.com/user/repo.git"),
1412            Some("https://raw.githubusercontent.com/user/repo/main/hub_index.json".to_string())
1413        );
1414    }
1415
1416    #[test]
1417    fn repo_to_index_url_direct_json() {
1418        assert_eq!(
1419            repo_to_index_url("https://example.com/my_index.json"),
1420            Some("https://example.com/my_index.json".to_string())
1421        );
1422    }
1423
1424    #[test]
1425    fn repo_to_index_url_unknown_host_no_json() {
1426        assert_eq!(repo_to_index_url("https://example.com/some-repo"), None);
1427    }
1428
1429    #[test]
1430    fn repo_to_index_url_local_path() {
1431        assert_eq!(repo_to_index_url("/home/user/my-pkg"), None);
1432    }
1433
1434    #[test]
1435    fn cache_key_stable() {
1436        let k1 = cache_key("https://example.com/index.json");
1437        let k2 = cache_key("https://example.com/index.json");
1438        assert_eq!(k1, k2);
1439        assert_eq!(k1.len(), 16); // 16 hex chars
1440    }
1441
1442    #[test]
1443    fn cache_key_different_urls() {
1444        let k1 = cache_key("https://a.com/index.json");
1445        let k2 = cache_key("https://b.com/index.json");
1446        assert_ne!(k1, k2);
1447    }
1448
1449    // NOTE: The init.lua meta / docstring parsing tests have moved to
1450    // `algocline_core::pkg::tests` along with the parser itself. The
1451    // `hub.rs` call-path tests now exercise the typed `PkgEntity` via
1452    // `build_index` / `merge` only.
1453
1454    #[test]
1455    fn merge_dedup_uses_hashset() {
1456        // Verify that merge correctly handles local-only packages
1457        // without O(n*m) behavior (structural test).
1458        let tmp = tempfile::tempdir().unwrap();
1459        let app_dir = AppDir::new(tmp.path().to_path_buf());
1460        let remote = HubIndex {
1461            schema_version: "hub_index/v0".into(),
1462            updated_at: String::new(),
1463            packages: vec![IndexEntry {
1464                entity: PkgEntity {
1465                    name: "remote_only".into(),
1466                    version: Some("1.0".into()),
1467                    description: Some("from remote".into()),
1468                    category: Some("test".into()),
1469                    docstring: None,
1470                },
1471                source: PackageSource::Unknown,
1472                card_count: 0,
1473                best_card: None,
1474            }],
1475        };
1476
1477        let results = merge(&app_dir, &remote).expect("merge over empty app_dir should succeed");
1478        // Should include remote_only + any locally installed packages
1479        assert!(results.iter().any(|r| r.entity.name == "remote_only"));
1480    }
1481
1482    #[test]
1483    fn matches_query_searches_docstring() {
1484        let result = SearchResult {
1485            entity: PkgEntity {
1486                name: "cascade".into(),
1487                version: Some("0.1.0".into()),
1488                description: Some("Multi-level routing".into()),
1489                category: Some("meta".into()),
1490                docstring: Some("Based on FrugalGPT. Uses Thompson Sampling.".into()),
1491            },
1492            source: PackageSource::Unknown,
1493            installed: true,
1494            card_count: 0,
1495            best_card: None,
1496            docstring_matched: None,
1497        };
1498
1499        assert!(matches_query(&result, "thompson"), "docstring match");
1500        assert!(matches_query(&result, "FrugalGPT"), "docstring match case");
1501        assert!(matches_query(&result, "routing"), "description match");
1502        assert!(!matches_query(&result, "bayesian"), "no match");
1503    }
1504
1505    // ─── SearchResult::to_value_with_optional_docstring ────────────
1506    //
1507    // `docstring` is not emitted by the default serde path (via the
1508    // `serialize_entity_without_docstring` custom serializer) and is
1509    // re-attached only when the projection path says so. These tests
1510    // pin the two branches of that helper — they are the hinge that
1511    // `verbose="full"` / `fields=["docstring"]` rely on.
1512
1513    fn sample_search_result() -> SearchResult {
1514        SearchResult {
1515            entity: PkgEntity {
1516                name: "cascade".into(),
1517                version: Some("0.1.0".into()),
1518                description: Some("Multi-level routing".into()),
1519                category: Some("reasoning".into()),
1520                docstring: Some("Based on FrugalGPT. Uses Thompson Sampling.".into()),
1521            },
1522            source: PackageSource::Git {
1523                url: "https://example.com/cascade".into(),
1524                rev: None,
1525            },
1526            installed: true,
1527            card_count: 3,
1528            best_card: None,
1529            docstring_matched: None,
1530        }
1531    }
1532
1533    #[test]
1534    fn to_value_default_omits_docstring() {
1535        let r = sample_search_result();
1536        let v = r.to_value_with_optional_docstring(false);
1537        let obj = v.as_object().expect("object");
1538        assert!(
1539            !obj.contains_key("docstring"),
1540            "default summary must not leak docstring"
1541        );
1542        assert_eq!(obj.get("name").and_then(|x| x.as_str()), Some("cascade"));
1543        // `docstring_matched` is Option<None> → `skip_serializing_if`
1544        // must omit it when the query did not mark a docstring-only hit.
1545        assert!(
1546            !obj.contains_key("docstring_matched"),
1547            "docstring_matched=None must be omitted"
1548        );
1549    }
1550
1551    #[test]
1552    fn to_value_include_reattaches_docstring() {
1553        let r = sample_search_result();
1554        let v = r.to_value_with_optional_docstring(true);
1555        let obj = v.as_object().expect("object");
1556        assert_eq!(
1557            obj.get("docstring").and_then(|x| x.as_str()),
1558            Some("Based on FrugalGPT. Uses Thompson Sampling.")
1559        );
1560    }
1561
1562    #[test]
1563    fn to_value_serializes_docstring_matched_when_set() {
1564        let mut r = sample_search_result();
1565        r.docstring_matched = Some(true);
1566        let v = r.to_value_with_optional_docstring(false);
1567        let obj = v.as_object().expect("object");
1568        assert_eq!(
1569            obj.get("docstring_matched").and_then(|x| x.as_bool()),
1570            Some(true)
1571        );
1572    }
1573
1574    // ─── projection glue ──────────────────────────────────────────
1575    //
1576    // These tests exercise the projection path that `hub_search` uses to
1577    // shape output: `resolve_fields` + `project_fields` applied to a
1578    // `to_value_with_optional_docstring`-serialized entry. They pin the
1579    // wf-sim-verbose contract: `fields` wins over `verbose`, default
1580    // summary preset excludes docstring, `full` preset includes
1581    // docstring, unknown keys silently skipped.
1582
1583    #[test]
1584    fn hub_search_default_summary_excludes_docstring() {
1585        let r = sample_search_result();
1586        let fields = resolve_fields(None, None, HUB_SEARCH_SUMMARY, HUB_SEARCH_FULL).unwrap();
1587        let include_docstring = fields.iter().any(|f| f == "docstring");
1588        let v = project_fields(
1589            r.to_value_with_optional_docstring(include_docstring),
1590            &fields,
1591        );
1592        let obj = v.as_object().expect("object");
1593        assert!(
1594            !obj.contains_key("docstring"),
1595            "summary preset must omit docstring"
1596        );
1597        // summary preset fields that are present on the sample entry
1598        for key in ["name", "version", "description", "category", "installed"] {
1599            assert!(obj.contains_key(key), "summary preset key {key} missing");
1600        }
1601    }
1602
1603    #[test]
1604    fn hub_search_verbose_full_includes_docstring() {
1605        let r = sample_search_result();
1606        let fields =
1607            resolve_fields(Some("full"), None, HUB_SEARCH_SUMMARY, HUB_SEARCH_FULL).unwrap();
1608        let include_docstring = fields.iter().any(|f| f == "docstring");
1609        let v = project_fields(
1610            r.to_value_with_optional_docstring(include_docstring),
1611            &fields,
1612        );
1613        let obj = v.as_object().expect("object");
1614        assert_eq!(
1615            obj.get("docstring").and_then(|x| x.as_str()),
1616            Some("Based on FrugalGPT. Uses Thompson Sampling.")
1617        );
1618        // full preset superset keys
1619        for key in ["source", "card_count"] {
1620            assert!(obj.contains_key(key), "full preset key {key} missing");
1621        }
1622    }
1623
1624    #[test]
1625    fn hub_search_fields_beats_verbose() {
1626        let r = sample_search_result();
1627        let explicit = vec!["name".to_string(), "docstring".to_string()];
1628        // verbose=summary normally excludes docstring, but explicit
1629        // fields must win.
1630        let fields = resolve_fields(
1631            Some("summary"),
1632            Some(&explicit),
1633            HUB_SEARCH_SUMMARY,
1634            HUB_SEARCH_FULL,
1635        )
1636        .unwrap();
1637        let include_docstring = fields.iter().any(|f| f == "docstring");
1638        let v = project_fields(
1639            r.to_value_with_optional_docstring(include_docstring),
1640            &fields,
1641        );
1642        let obj = v.as_object().expect("object");
1643        assert_eq!(obj.len(), 2, "only the two requested fields");
1644        assert!(obj.contains_key("name"));
1645        assert!(obj.contains_key("docstring"));
1646    }
1647
1648    #[test]
1649    fn hub_search_fields_unknown_key_silently_skipped() {
1650        let r = sample_search_result();
1651        let explicit = vec!["name".to_string(), "bogus".to_string()];
1652        let fields =
1653            resolve_fields(None, Some(&explicit), HUB_SEARCH_SUMMARY, HUB_SEARCH_FULL).unwrap();
1654        let v = project_fields(r.to_value_with_optional_docstring(false), &fields);
1655        let obj = v.as_object().expect("object");
1656        assert_eq!(obj.len(), 1, "bogus must not appear");
1657        assert!(obj.contains_key("name"));
1658    }
1659
1660    #[test]
1661    fn hub_search_invalid_verbose_errors() {
1662        let err =
1663            resolve_fields(Some("fat"), None, HUB_SEARCH_SUMMARY, HUB_SEARCH_FULL).unwrap_err();
1664        assert!(
1665            err.contains("fat"),
1666            "error must mention the offending value"
1667        );
1668    }
1669
1670    // ─── docstring_matched classification ─────────────────────────
1671    //
1672    // The query-time classification rule: `docstring_matched = Some(true)`
1673    // only when the query hit docstring AND missed name/description/
1674    // category; otherwise `None` (and therefore omitted from output).
1675    // The logic lives inline in `hub_search`; we re-create it here over a
1676    // tiny local helper so the three cases stay pinned as a contract.
1677
1678    fn classify(r: &SearchResult, query: &str) -> Option<bool> {
1679        let ql = query.to_lowercase();
1680        if query.is_empty() {
1681            return None;
1682        }
1683        let empty = String::new();
1684        let pkg = &r.entity;
1685        let other_hit = pkg.name.to_lowercase().contains(&ql)
1686            || pkg
1687                .description
1688                .as_ref()
1689                .unwrap_or(&empty)
1690                .to_lowercase()
1691                .contains(&ql)
1692            || pkg
1693                .category
1694                .as_ref()
1695                .unwrap_or(&empty)
1696                .to_lowercase()
1697                .contains(&ql);
1698        let doc_hit = pkg
1699            .docstring
1700            .as_ref()
1701            .unwrap_or(&empty)
1702            .to_lowercase()
1703            .contains(&ql);
1704        if !other_hit && doc_hit {
1705            Some(true)
1706        } else {
1707            None
1708        }
1709    }
1710
1711    #[test]
1712    fn docstring_matched_true_when_only_docstring_hits() {
1713        let r = sample_search_result();
1714        // "Thompson" appears only in docstring of the sample entry.
1715        assert_eq!(classify(&r, "thompson"), Some(true));
1716    }
1717
1718    #[test]
1719    fn docstring_matched_none_when_name_also_hits() {
1720        let r = sample_search_result();
1721        // "cascade" hits the name; docstring match is irrelevant now.
1722        assert_eq!(classify(&r, "cascade"), None);
1723    }
1724
1725    #[test]
1726    fn docstring_matched_none_when_description_hits() {
1727        let r = sample_search_result();
1728        // "routing" hits description; should be None.
1729        assert_eq!(classify(&r, "routing"), None);
1730    }
1731
1732    #[test]
1733    fn docstring_matched_none_when_query_empty() {
1734        let r = sample_search_result();
1735        assert_eq!(classify(&r, ""), None);
1736    }
1737
1738    // ─── filter fold (legacy params → filter map) ─────────────────
1739    //
1740    // Behavioural rule: legacy `category` / `installed_only=true` fold
1741    // into the filter map only when the corresponding key is not
1742    // already set (explicit `filter` wins). `installed_only=false` is a
1743    // no-op (preserves prior semantics).
1744
1745    fn build_filter_map(
1746        category: Option<&str>,
1747        installed_only: Option<bool>,
1748        explicit: Option<HashMap<String, serde_json::Value>>,
1749    ) -> HashMap<String, serde_json::Value> {
1750        let mut filter_map = explicit.unwrap_or_default();
1751        if let Some(cat) = category {
1752            filter_map
1753                .entry("category".to_string())
1754                .or_insert_with(|| serde_json::Value::String(cat.to_string()));
1755        }
1756        if let Some(only) = installed_only {
1757            if only {
1758                filter_map
1759                    .entry("installed".to_string())
1760                    .or_insert(serde_json::Value::Bool(true));
1761            }
1762        }
1763        filter_map
1764    }
1765
1766    #[test]
1767    fn filter_by_category_via_legacy_param() {
1768        let m = build_filter_map(Some("reasoning"), None, None);
1769        assert_eq!(
1770            m.get("category"),
1771            Some(&serde_json::Value::String("reasoning".to_string()))
1772        );
1773    }
1774
1775    #[test]
1776    fn filter_by_installed_only_via_legacy_param() {
1777        let m = build_filter_map(None, Some(true), None);
1778        assert_eq!(m.get("installed"), Some(&serde_json::Value::Bool(true)));
1779    }
1780
1781    #[test]
1782    fn filter_installed_only_false_is_noop() {
1783        let m = build_filter_map(None, Some(false), None);
1784        assert!(
1785            !m.contains_key("installed"),
1786            "installed_only=false should not fold in"
1787        );
1788    }
1789
1790    #[test]
1791    fn filter_beats_legacy_param_on_conflict() {
1792        // Explicit filter says category=meta; legacy says reasoning.
1793        // Explicit must win.
1794        let mut explicit = HashMap::new();
1795        explicit.insert(
1796            "category".to_string(),
1797            serde_json::Value::String("meta".to_string()),
1798        );
1799        let m = build_filter_map(Some("reasoning"), None, Some(explicit));
1800        assert_eq!(
1801            m.get("category"),
1802            Some(&serde_json::Value::String("meta".to_string()))
1803        );
1804    }
1805
1806    #[test]
1807    fn filter_merges_legacy_when_no_conflict() {
1808        // Explicit sets a different key; legacy category should still
1809        // be folded in.
1810        let mut explicit = HashMap::new();
1811        explicit.insert("installed".to_string(), serde_json::Value::Bool(true));
1812        let m = build_filter_map(Some("reasoning"), None, Some(explicit));
1813        assert_eq!(
1814            m.get("category"),
1815            Some(&serde_json::Value::String("reasoning".to_string()))
1816        );
1817        assert_eq!(m.get("installed"), Some(&serde_json::Value::Bool(true)));
1818    }
1819
1820    // ─── load_registries: file-absent vs. corrupt JSON ────────────
1821
1822    #[test]
1823    fn load_registries_missing_file_returns_default() {
1824        let tmp = tempfile::tempdir().unwrap();
1825        let app_dir = AppDir::new(tmp.path().to_path_buf());
1826        // No hub_registries.json created — must return Ok(empty).
1827        let result = load_registries(&app_dir);
1828        assert!(result.is_ok(), "missing file should be Ok: {result:?}");
1829        assert!(result.unwrap().registries.is_empty());
1830    }
1831
1832    #[test]
1833    fn load_registries_corrupt_json_returns_err() {
1834        let tmp = tempfile::tempdir().unwrap();
1835        let app_dir = AppDir::new(tmp.path().to_path_buf());
1836        // Write corrupt JSON to the registries path.
1837        let path = app_dir.hub_registries_json();
1838        std::fs::create_dir_all(path.parent().unwrap()).unwrap();
1839        std::fs::write(&path, b"not valid json {{{").unwrap();
1840        let result = load_registries(&app_dir);
1841        assert!(result.is_err(), "corrupt JSON must propagate Err");
1842        let msg = result.unwrap_err().to_string();
1843        assert!(
1844            msg.contains("parse"),
1845            "error message should mention parse: {msg}"
1846        );
1847    }
1848
1849    #[test]
1850    fn load_registries_valid_file_deserializes() {
1851        let tmp = tempfile::tempdir().unwrap();
1852        let app_dir = AppDir::new(tmp.path().to_path_buf());
1853        let path = app_dir.hub_registries_json();
1854        std::fs::create_dir_all(path.parent().unwrap()).unwrap();
1855        let content = r#"{"registries":[{"source":"https://github.com/user/repo","origin":"pkg_install","added_at":"2026-01-01T00:00:00Z"}]}"#;
1856        std::fs::write(&path, content).unwrap();
1857        let result = load_registries(&app_dir);
1858        assert!(result.is_ok(), "valid JSON must parse Ok: {result:?}");
1859        let reg = result.unwrap();
1860        assert_eq!(reg.registries.len(), 1);
1861        assert_eq!(reg.registries[0].source, "https://github.com/user/repo");
1862    }
1863
1864    // ─── default sort verification ────────────────────────────────
1865
1866    #[test]
1867    fn default_sort_is_minus_installed_name() {
1868        let keys = parse_sort("-installed,name").unwrap();
1869        assert_eq!(keys.len(), 2);
1870        assert_eq!(keys[0].key, "installed");
1871        assert!(keys[0].desc, "installed must sort desc (true first)");
1872        assert_eq!(keys[1].key, "name");
1873        assert!(!keys[1].desc);
1874
1875        // Apply it against a small vec and confirm the expected order.
1876        let mut items = vec![
1877            serde_json::json!({"installed": false, "name": "zeta"}),
1878            serde_json::json!({"installed": true, "name": "mu"}),
1879            serde_json::json!({"installed": false, "name": "alpha"}),
1880            serde_json::json!({"installed": true, "name": "beta"}),
1881        ];
1882        apply_sort_by_value(&mut items, &keys);
1883        let names: Vec<&str> = items
1884            .iter()
1885            .map(|v| v.get("name").and_then(|x| x.as_str()).unwrap_or(""))
1886            .collect();
1887        assert_eq!(names, vec!["beta", "mu", "alpha", "zeta"]);
1888    }
1889
1890    // ─── Phase 3 MED batch: error-propagation tests ───────────────
1891
1892    // Site 1: collection_url_from_config
1893
1894    #[test]
1895    fn collection_url_from_config_absent_returns_ok_none() {
1896        let tmp = tempfile::tempdir().unwrap();
1897        let app_dir = AppDir::new(tmp.path().to_path_buf());
1898        // No config.toml created — absent file must be Ok(None), not Err.
1899        let result = collection_url_from_config(&app_dir);
1900        assert!(
1901            matches!(result, Ok(None)),
1902            "absent config.toml must return Ok(None), got {result:?}"
1903        );
1904    }
1905
1906    #[test]
1907    fn collection_url_from_config_corrupt_toml_returns_err() {
1908        let tmp = tempfile::tempdir().unwrap();
1909        let app_dir = AppDir::new(tmp.path().to_path_buf());
1910        let path = app_dir.config_toml();
1911        std::fs::create_dir_all(path.parent().unwrap()).unwrap();
1912        std::fs::write(&path, b"[hub\ncollection_url = broken{{{{").unwrap();
1913        let result = collection_url_from_config(&app_dir);
1914        assert!(
1915            result.is_err(),
1916            "corrupt TOML must return Err, got {result:?}"
1917        );
1918    }
1919
1920    #[test]
1921    fn collection_url_from_config_valid_returns_url() {
1922        let tmp = tempfile::tempdir().unwrap();
1923        let app_dir = AppDir::new(tmp.path().to_path_buf());
1924        let path = app_dir.config_toml();
1925        std::fs::create_dir_all(path.parent().unwrap()).unwrap();
1926        std::fs::write(
1927            &path,
1928            b"[hub]\ncollection_url = \"https://example.com/hub_index.json\"\n",
1929        )
1930        .unwrap();
1931        let result = collection_url_from_config(&app_dir);
1932        assert_eq!(
1933            result.unwrap(),
1934            Some("https://example.com/hub_index.json".to_string())
1935        );
1936    }
1937
1938    #[test]
1939    fn collection_url_from_config_no_hub_section_returns_none() {
1940        let tmp = tempfile::tempdir().unwrap();
1941        let app_dir = AppDir::new(tmp.path().to_path_buf());
1942        let path = app_dir.config_toml();
1943        std::fs::create_dir_all(path.parent().unwrap()).unwrap();
1944        std::fs::write(&path, b"[some_other_section]\nfoo = \"bar\"\n").unwrap();
1945        let result = collection_url_from_config(&app_dir);
1946        assert!(
1947            matches!(result, Ok(None)),
1948            "config without [hub] must return Ok(None), got {result:?}"
1949        );
1950    }
1951
1952    // Site 2: load_cached
1953
1954    #[test]
1955    fn load_cached_absent_returns_ok_none() {
1956        let tmp = tempfile::tempdir().unwrap();
1957        let app_dir = AppDir::new(tmp.path().to_path_buf());
1958        let result = load_cached(&app_dir, "https://example.com/index.json");
1959        assert!(
1960            matches!(result, Ok(None)),
1961            "absent cache file must return Ok(None), got {result:?}"
1962        );
1963    }
1964
1965    #[test]
1966    fn load_cached_corrupt_json_within_ttl_returns_err() {
1967        let tmp = tempfile::tempdir().unwrap();
1968        let app_dir = AppDir::new(tmp.path().to_path_buf());
1969        let url = "https://example.com/index.json";
1970        let dir = cache_dir(&app_dir);
1971        std::fs::create_dir_all(&dir).unwrap();
1972        let path = dir.join(format!("{}.json", cache_key(url)));
1973        std::fs::write(&path, b"not valid json {{{{").unwrap();
1974        // file is freshly written so within TTL
1975        let result = load_cached(&app_dir, url);
1976        assert!(
1977            result.is_err(),
1978            "corrupt JSON within TTL must return Err, got {result:?}"
1979        );
1980    }
1981
1982    #[test]
1983    fn load_cached_valid_json_within_ttl_returns_index() {
1984        let tmp = tempfile::tempdir().unwrap();
1985        let app_dir = AppDir::new(tmp.path().to_path_buf());
1986        let url = "https://example.com/index.json";
1987        let dir = cache_dir(&app_dir);
1988        std::fs::create_dir_all(&dir).unwrap();
1989        let path = dir.join(format!("{}.json", cache_key(url)));
1990        let index_json = r#"{"schema_version":"hub_index/v0","updated_at":"2026-01-01T00:00:00Z","packages":[]}"#;
1991        std::fs::write(&path, index_json).unwrap();
1992        let result = load_cached(&app_dir, url);
1993        assert!(
1994            matches!(result, Ok(Some(_))),
1995            "valid JSON within TTL must return Ok(Some(_)), got {result:?}"
1996        );
1997    }
1998
1999    // Site 3: count_evals_for_pkg
2000
2001    #[test]
2002    fn count_evals_for_pkg_absent_dir_returns_zero_no_warnings() {
2003        let tmp = tempfile::tempdir().unwrap();
2004        let app_dir = AppDir::new(tmp.path().to_path_buf());
2005        let mut warnings: Vec<String> = Vec::new();
2006        let count = count_evals_for_pkg(&app_dir, "cot", &mut warnings);
2007        assert_eq!(count, 0, "absent evals dir must return 0");
2008        assert!(
2009            warnings.is_empty(),
2010            "absent evals dir must produce no warnings, got {warnings:?}"
2011        );
2012    }
2013
2014    #[test]
2015    fn count_evals_for_pkg_corrupt_meta_surfaces_warning() {
2016        let tmp = tempfile::tempdir().unwrap();
2017        let app_dir = AppDir::new(tmp.path().to_path_buf());
2018        let evals_dir = app_dir.evals_dir();
2019        std::fs::create_dir_all(&evals_dir).unwrap();
2020
2021        // Write a result JSON stub so the file is scanned.
2022        std::fs::write(evals_dir.join("cot_9999.json"), b"{}").unwrap();
2023        // Write a corrupt meta.json for the same stem.
2024        std::fs::write(evals_dir.join("cot_9999.meta.json"), b"not json {{{{").unwrap();
2025
2026        let mut warnings: Vec<String> = Vec::new();
2027        let _count = count_evals_for_pkg(&app_dir, "cot", &mut warnings);
2028        assert!(
2029            !warnings.is_empty(),
2030            "corrupt meta.json must produce at least one warning, got {warnings:?}"
2031        );
2032        assert!(
2033            warnings[0].contains("parse"),
2034            "warning must mention parse: {}",
2035            warnings[0]
2036        );
2037    }
2038
2039    #[test]
2040    fn count_evals_for_pkg_valid_meta_counts_correctly() {
2041        let tmp = tempfile::tempdir().unwrap();
2042        let app_dir = AppDir::new(tmp.path().to_path_buf());
2043        let evals_dir = app_dir.evals_dir();
2044        std::fs::create_dir_all(&evals_dir).unwrap();
2045
2046        // Write a result JSON + valid meta for strategy "cot".
2047        let meta = r#"{"eval_id":"cot_1","strategy":"cot","timestamp":1}"#;
2048        std::fs::write(evals_dir.join("cot_1.json"), b"{}").unwrap();
2049        std::fs::write(evals_dir.join("cot_1.meta.json"), meta).unwrap();
2050
2051        let mut warnings: Vec<String> = Vec::new();
2052        let count = count_evals_for_pkg(&app_dir, "cot", &mut warnings);
2053        assert_eq!(count, 1, "should count 1 valid eval");
2054        assert!(warnings.is_empty(), "no warnings expected: {warnings:?}");
2055    }
2056}