Skip to main content

algocline_engine/
card.rs

1//! Card storage — immutable run-result snapshots.
2//!
3//! A Card is a frozen record of a strategy run: identity, parameters,
4//! model, scenario, aggregate stats, and (optionally) per-case detail.
5//! Cards are **immutable** — once written they are never modified, only
6//! annotated via additive `append`.  Mutable **aliases** point to a
7//! Card and can be rebound freely.
8//!
9//! ## Design principles
10//!
11//! 1. **Minimal REQUIRED, maximal OPTIONAL** — v0 needs only 4 fields;
12//!    lightweight "ran this pkg" records and heavy optimize snapshots
13//!    share the same schema.
14//! 2. **Immutable append-only** — no overwrite, no delete.  New data is
15//!    added via `append` (new top-level keys only) or by creating a new
16//!    Card with a fresh `card_id`.
17//! 3. **Two-tier storage** — TOML for human-readable aggregate, JSONL
18//!    sidecar for machine-parseable per-case detail.
19//! 4. **File-primary** — files are the source of truth; in-memory state
20//!    is cache.  Cards can be copied, diffed, and version-controlled.
21//!
22//! ## Storage layout (two-tier)
23//!
24//! | Tier | File | Content |
25//! |------|------|---------|
26//! | **Tier 1** | `~/.algocline/cards/{pkg}/{card_id}.toml` | Aggregate scalars, decisions, identity, params |
27//! | **Tier 2** | `~/.algocline/cards/{pkg}/{card_id}.samples.jsonl` | Per-case raw data (JSONL, write-once) |
28//!
29//! Tier 1 holds a shareable summary (a few KB). Tier 2 holds per-case
30//! detail ��� the engine does not interpret its columns; packages define
31//! their own schema.
32//!
33//! Alias table: `~/.algocline/cards/_aliases.toml` (global).
34//!
35//! ## card_id naming
36//!
37//! `{pkg}_{model_short}_{compact_ts}_{hash6}`
38//!
39//! - `compact_ts`: `YYYYMMDDTHHMMSS` in UTC
40//! - `hash6`: first 6 hex chars of DJB2 param fingerprint
41//! - Example: `cot_opus46_20260412T061500_a3f9c1`
42//!
43//! ## v0 schema (frozen)
44//!
45//! ### REQUIRED (minimum valid Card)
46//!
47//! | Field | Type | Example |
48//! |-------|------|---------|
49//! | `schema_version` | string | `"card/v0"` |
50//! | `card_id` | string | `"cot_opus46_20260412T061500_a3f9c1"` |
51//! | `created_at` | string (RFC 3339) | `"2026-04-12T06:15:00Z"` |
52//! | `[pkg].name` | string | `"cot"` |
53//!
54//! ### OPTIONAL (auto-injected where possible)
55//!
56//! | Section | Fields |
57//! |---------|--------|
58//! | `[pkg]` | `version`, `category`, `source`, `source_ref`, `source_sha` |
59//! | `[runtime]` | `alc_version`, `lua_version`, `host_os`, `git_sha` |
60//! | `[model]` | `provider`, `id`, `id_short`, `cutoff` |
61//! | `[params]` | Free-form ctx snapshot; `param_fingerprint` for DJB2 hash |
62//! | `[scenario]` | `name`, `source`, `case_count`, `grader` |
63//! | `[stats]` | `pass_rate`, `mean_score`, `std`, `median`, `min`, `max`, `n` |
64//! | `[stats.by_bucket]` | Disaggregated sub-bucket stats (array of tables) |
65//! | `[cost]` | `llm_calls`, `input_tokens`, `output_tokens`, `elapsed_ms`, `usd_estimate` |
66//! | `[optimize]` | `target`, `search`, `rounds_used`, `top_k` (for optimize Cards) |
67//! | `[metadata]` | Free-form escape hatch for unstandardized fields |
68//!
69//! ## Lua API (`alc.card.*`)
70//!
71//! | Function | Description |
72//! |----------|-------------|
73//! | `create(table)` | Write new Card (Tier 1). Returns `{ card_id, path }` |
74//! | `get(card_id)` | Read Card by id. Returns table or nil |
75//! | `list(filter?)` | List Cards as summaries (newest first) |
76//! | `find(query?)` | Query with sort / filter / limit |
77//! | `append(card_id, fields)` | Additive-only annotation (new keys only) |
78//! | `alias_set(name, card_id, opts?)` | Pin mutable alias |
79//! | `alias_list(filter?)` | List aliases |
80//! | `get_by_alias(name)` | Resolve alias → full Card |
81//! | `write_samples(card_id, samples)` | Write Tier 2 sidecar (write-once) |
82//! | `read_samples(card_id, opts?)` | Read Tier 2 with offset/limit paging |
83
84use std::fs;
85use std::path::PathBuf;
86
87use serde_json::{json, Value as Json};
88
89pub const SCHEMA_VERSION: &str = "card/v0";
90
91/// Resolve the cards root directory, creating it if needed.
92fn cards_dir() -> Result<PathBuf, String> {
93    let home = dirs::home_dir().ok_or("Cannot determine home directory")?;
94    let dir = home.join(".algocline").join("cards");
95    if !dir.exists() {
96        fs::create_dir_all(&dir).map_err(|e| format!("Failed to create cards dir: {e}"))?;
97    }
98    Ok(dir)
99}
100
101/// Per-pkg subdirectory. Validates pkg name to prevent path traversal.
102fn pkg_dir(pkg: &str) -> Result<PathBuf, String> {
103    validate_name(pkg, "pkg")?;
104    let dir = cards_dir()?.join(pkg);
105    if !dir.exists() {
106        fs::create_dir_all(&dir).map_err(|e| format!("Failed to create pkg dir: {e}"))?;
107    }
108    Ok(dir)
109}
110
111fn validate_name(name: &str, kind: &str) -> Result<(), String> {
112    if name.is_empty()
113        || name.contains('/')
114        || name.contains('\\')
115        || name.contains("..")
116        || name.contains('\0')
117    {
118        return Err(format!("Invalid {kind} name: '{name}'"));
119    }
120    Ok(())
121}
122
123/// DJB2 hash, hex-encoded. Used for param_fingerprint and card_id hash segment.
124fn djb2_hex(s: &str) -> String {
125    let mut h: u64 = 5381;
126    for b in s.bytes() {
127        h = h.wrapping_mul(33).wrapping_add(b as u64);
128    }
129    format!("{h:016x}")
130}
131
132/// Short-hash: last 6 hex chars of djb2.
133///
134/// DJB2's high bits are dominated by the `5381 * 33^n` term (same for any
135/// input of equal length), so the top hex digits collide easily for same-
136/// length inputs that differ only in a few byte positions. The low bits,
137/// driven by the most-recent bytes, mix well enough for short-hash use.
138fn hash6(s: &str) -> String {
139    let hex = djb2_hex(s);
140    let start = hex.len().saturating_sub(6);
141    hex[start..].to_string()
142}
143
144/// Stable serialization of a JSON value for hashing (sorted keys).
145fn stable_json(v: &Json) -> String {
146    let mut buf = String::new();
147    stable_json_into(v, &mut buf);
148    buf
149}
150fn stable_json_into(v: &Json, buf: &mut String) {
151    match v {
152        Json::Null => buf.push_str("null"),
153        Json::Bool(b) => buf.push_str(if *b { "true" } else { "false" }),
154        Json::Number(n) => buf.push_str(&n.to_string()),
155        Json::String(s) => {
156            buf.push('"');
157            buf.push_str(s);
158            buf.push('"');
159        }
160        Json::Array(a) => {
161            buf.push('[');
162            for (i, item) in a.iter().enumerate() {
163                if i > 0 {
164                    buf.push(',');
165                }
166                stable_json_into(item, buf);
167            }
168            buf.push(']');
169        }
170        Json::Object(m) => {
171            let mut keys: Vec<&String> = m.keys().collect();
172            keys.sort();
173            buf.push('{');
174            for (i, k) in keys.iter().enumerate() {
175                if i > 0 {
176                    buf.push(',');
177                }
178                buf.push('"');
179                buf.push_str(k);
180                buf.push_str("\":");
181                stable_json_into(&m[*k], buf);
182            }
183            buf.push('}');
184        }
185    }
186}
187
188/// Derive a short model id (e.g. "claude-opus-4-6" -> "opus46").
189/// v0: best-effort. Falls back to "model" if input is empty.
190fn short_model(id: &str) -> String {
191    if id.is_empty() {
192        return "model".into();
193    }
194    // Strip common vendor prefixes.
195    let stripped = id
196        .strip_prefix("claude-")
197        .or_else(|| id.strip_prefix("gpt-"))
198        .unwrap_or(id);
199    // Keep alnum only.
200    let s: String = stripped
201        .chars()
202        .filter(|c| c.is_ascii_alphanumeric())
203        .collect();
204    if s.is_empty() {
205        "model".into()
206    } else {
207        s
208    }
209}
210
211/// RFC3339 UTC "YYYY-MM-DDTHH:MM:SSZ" from current system time.
212fn now_rfc3339() -> String {
213    let secs = std::time::SystemTime::now()
214        .duration_since(std::time::UNIX_EPOCH)
215        .map(|d| d.as_secs())
216        .unwrap_or(0) as i64;
217    let days = secs.div_euclid(86400);
218    let tod = secs.rem_euclid(86400);
219    let (y, mo, d) = civil_from_days(days);
220    let hh = tod / 3600;
221    let mm = (tod % 3600) / 60;
222    let ss = tod % 60;
223    format!("{y:04}-{mo:02}-{d:02}T{hh:02}:{mm:02}:{ss:02}Z")
224}
225
226/// YYYYMMDDTHHMMSS for current UTC time (compact, sortable).
227///
228/// Used in card_id generation so that:
229///   - rapid successive runs don't collide on the hash6 segment
230///   - string sort of card_id = chronological order
231fn now_compact() -> String {
232    let secs = std::time::SystemTime::now()
233        .duration_since(std::time::UNIX_EPOCH)
234        .map(|d| d.as_secs())
235        .unwrap_or(0) as i64;
236    let days = secs.div_euclid(86400);
237    let tod = secs.rem_euclid(86400);
238    let (y, mo, d) = civil_from_days(days);
239    let hh = tod / 3600;
240    let mm = (tod % 3600) / 60;
241    let ss = tod % 60;
242    format!("{y:04}{mo:02}{d:02}T{hh:02}{mm:02}{ss:02}")
243}
244
245/// Howard Hinnant's civil_from_days algorithm.
246fn civil_from_days(z: i64) -> (i32, u32, u32) {
247    let z = z + 719468;
248    let era = if z >= 0 { z } else { z - 146096 } / 146097;
249    let doe = (z - era * 146097) as u64;
250    let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365;
251    let y = yoe as i64 + era * 400;
252    let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
253    let mp = (5 * doy + 2) / 153;
254    let d = (doy - (153 * mp + 2) / 5 + 1) as u32;
255    let m = (if mp < 10 { mp + 3 } else { mp - 9 }) as u32;
256    let y = y + if m <= 2 { 1 } else { 0 };
257    (y as i32, m, d)
258}
259
260/// Converter: serde_json::Value -> toml::Value.
261/// Nulls are dropped (TOML has no null). Mixed-type arrays are allowed in TOML 1.0+.
262fn json_to_toml(v: Json) -> Result<toml::Value, String> {
263    Ok(match v {
264        Json::Null => return Err("TOML does not support null values".into()),
265        Json::Bool(b) => toml::Value::Boolean(b),
266        Json::Number(n) => {
267            if let Some(i) = n.as_i64() {
268                toml::Value::Integer(i)
269            } else if let Some(f) = n.as_f64() {
270                toml::Value::Float(f)
271            } else {
272                return Err(format!("Unsupported number: {n}"));
273            }
274        }
275        Json::String(s) => toml::Value::String(s),
276        Json::Array(a) => {
277            let mut out = Vec::with_capacity(a.len());
278            for item in a {
279                if !item.is_null() {
280                    out.push(json_to_toml(item)?);
281                }
282            }
283            toml::Value::Array(out)
284        }
285        Json::Object(m) => {
286            let mut table = toml::map::Map::new();
287            for (k, val) in m {
288                if val.is_null() {
289                    continue;
290                }
291                table.insert(k, json_to_toml(val)?);
292            }
293            toml::Value::Table(table)
294        }
295    })
296}
297
298/// Converter: toml::Value -> serde_json::Value (for alc.card.get()).
299fn toml_to_json(v: toml::Value) -> Json {
300    match v {
301        toml::Value::String(s) => Json::String(s),
302        toml::Value::Integer(i) => json!(i),
303        toml::Value::Float(f) => json!(f),
304        toml::Value::Boolean(b) => Json::Bool(b),
305        toml::Value::Datetime(dt) => Json::String(dt.to_string()),
306        toml::Value::Array(a) => Json::Array(a.into_iter().map(toml_to_json).collect()),
307        toml::Value::Table(t) => {
308            let mut m = serde_json::Map::new();
309            for (k, v) in t {
310                m.insert(k, toml_to_json(v));
311            }
312            Json::Object(m)
313        }
314    }
315}
316
317/// Extract [pkg].name from an input JSON object. REQUIRED.
318fn require_pkg_name(input: &Json) -> Result<String, String> {
319    let name = input
320        .get("pkg")
321        .and_then(|p| p.get("name"))
322        .and_then(|n| n.as_str())
323        .ok_or_else(|| "alc.card.create: pkg.name is required".to_string())?
324        .to_string();
325    validate_name(&name, "pkg")?;
326    Ok(name)
327}
328
329/// Main create entry. Returns (card_id, absolute_path).
330pub fn create(mut input: Json) -> Result<(String, PathBuf), String> {
331    if !input.is_object() {
332        return Err("alc.card.create: input must be a table".into());
333    }
334    let pkg_name = require_pkg_name(&input)?;
335    let obj = input.as_object_mut().unwrap();
336
337    // ─── Auto-inject REQUIRED fields ──────────────────────────
338    obj.entry("schema_version".to_string())
339        .or_insert_with(|| json!(SCHEMA_VERSION));
340    obj.entry("created_at".to_string())
341        .or_insert_with(|| json!(now_rfc3339()));
342    obj.entry("created_by".to_string())
343        .or_insert_with(|| json!(format!("alc@{}", env!("CARGO_PKG_VERSION"))));
344
345    // ─── param_fingerprint (if [params] present) ──────────────
346    if let Some(params) = obj.get("params").cloned() {
347        if params.is_object() {
348            let fp = djb2_hex(&stable_json(&params));
349            obj.insert("param_fingerprint".to_string(), json!(fp));
350        }
351    }
352
353    // ─── card_id generation (if absent) ───────────────────────
354    let card_id = match obj.get("card_id").and_then(|v| v.as_str()) {
355        Some(id) if !id.is_empty() => id.to_string(),
356        _ => {
357            let model_id = obj
358                .get("model")
359                .and_then(|m| m.get("id"))
360                .and_then(|v| v.as_str())
361                .unwrap_or("");
362            let model_short = short_model(model_id);
363            let ts = now_compact();
364            let fp_seed = stable_json(&Json::Object(obj.clone()));
365            let h = hash6(&fp_seed);
366            format!("{pkg_name}_{model_short}_{ts}_{h}")
367        }
368    };
369    validate_name(&card_id, "card_id")?;
370    obj.insert("card_id".to_string(), json!(card_id.clone()));
371
372    // ─── Write TOML atomically ────────────────────────────────
373    let dir = pkg_dir(&pkg_name)?;
374    let path = dir.join(format!("{card_id}.toml"));
375    if path.exists() {
376        return Err(format!(
377            "alc.card.create: card '{card_id}' already exists (immutable)"
378        ));
379    }
380    let toml_val = json_to_toml(input)?;
381    let text = toml::to_string_pretty(&toml_val)
382        .map_err(|e| format!("Failed to serialize card TOML: {e}"))?;
383    let tmp = path.with_extension("toml.tmp");
384    fs::write(&tmp, &text).map_err(|e| format!("Failed to write card tmp: {e}"))?;
385    fs::rename(&tmp, &path).map_err(|e| format!("Failed to rename card file: {e}"))?;
386
387    Ok((card_id, path))
388}
389
390/// Search cards dir for `{card_id}.toml`.
391fn find_card_path(card_id: &str) -> Result<Option<PathBuf>, String> {
392    validate_name(card_id, "card_id")?;
393    let root = cards_dir()?;
394    let entries = fs::read_dir(&root).map_err(|e| format!("Failed to read cards dir: {e}"))?;
395    for entry in entries.flatten() {
396        let p = entry.path();
397        if p.is_dir() {
398            let candidate = p.join(format!("{card_id}.toml"));
399            if candidate.exists() {
400                return Ok(Some(candidate));
401            }
402        }
403    }
404    Ok(None)
405}
406
407/// Read a Card by id. Returns None if not found.
408pub fn get(card_id: &str) -> Result<Option<Json>, String> {
409    let path = match find_card_path(card_id)? {
410        Some(p) => p,
411        None => return Ok(None),
412    };
413    let text =
414        fs::read_to_string(&path).map_err(|e| format!("Failed to read card '{card_id}': {e}"))?;
415    let val: toml::Value =
416        toml::from_str(&text).map_err(|e| format!("Failed to parse card '{card_id}': {e}"))?;
417    Ok(Some(toml_to_json(val)))
418}
419
420/// Summary row for `alc.card.list()`.
421#[derive(Debug, Clone)]
422pub struct Summary {
423    pub card_id: String,
424    pub pkg: String,
425    pub created_at: Option<String>,
426    pub model: Option<String>,
427    pub scenario: Option<String>,
428    pub pass_rate: Option<f64>,
429}
430
431impl Summary {
432    fn to_json(&self) -> Json {
433        let mut m = serde_json::Map::new();
434        m.insert("card_id".into(), json!(self.card_id));
435        m.insert("pkg".into(), json!(self.pkg));
436        if let Some(v) = &self.created_at {
437            m.insert("created_at".into(), json!(v));
438        }
439        if let Some(v) = &self.model {
440            m.insert("model".into(), json!(v));
441        }
442        if let Some(v) = &self.scenario {
443            m.insert("scenario".into(), json!(v));
444        }
445        if let Some(v) = self.pass_rate {
446            m.insert("pass_rate".into(), json!(v));
447        }
448        Json::Object(m)
449    }
450}
451
452fn summarize(path: &std::path::Path, pkg: &str) -> Option<Summary> {
453    let text = fs::read_to_string(path).ok()?;
454    let val: toml::Value = toml::from_str(&text).ok()?;
455    let card_id = val
456        .get("card_id")
457        .and_then(|v| v.as_str())
458        .or_else(|| path.file_stem().and_then(|s| s.to_str()))?
459        .to_string();
460    let created_at = val
461        .get("created_at")
462        .and_then(|v| v.as_str())
463        .map(String::from);
464    let model = val
465        .get("model")
466        .and_then(|m| m.get("id"))
467        .and_then(|v| v.as_str())
468        .map(String::from);
469    let scenario = val
470        .get("scenario")
471        .and_then(|s| s.get("name"))
472        .and_then(|v| v.as_str())
473        .map(String::from);
474    let pass_rate = val
475        .get("stats")
476        .and_then(|s| s.get("pass_rate"))
477        .and_then(|v| v.as_float());
478    Some(Summary {
479        card_id,
480        pkg: pkg.to_string(),
481        created_at,
482        model,
483        scenario,
484        pass_rate,
485    })
486}
487
488/// List cards. `pkg_filter = Some("name")` restricts to that pkg subdir.
489pub fn list(pkg_filter: Option<&str>) -> Result<Vec<Summary>, String> {
490    let root = cards_dir()?;
491    let mut out = Vec::new();
492
493    let pkg_dirs: Vec<PathBuf> = if let Some(p) = pkg_filter {
494        validate_name(p, "pkg")?;
495        let d = root.join(p);
496        if d.is_dir() {
497            vec![d]
498        } else {
499            vec![]
500        }
501    } else {
502        fs::read_dir(&root)
503            .map_err(|e| format!("Failed to read cards dir: {e}"))?
504            .flatten()
505            .map(|e| e.path())
506            .filter(|p| p.is_dir())
507            .collect()
508    };
509
510    for pdir in pkg_dirs {
511        let pkg = pdir
512            .file_name()
513            .and_then(|s| s.to_str())
514            .unwrap_or("")
515            .to_string();
516        let entries = match fs::read_dir(&pdir) {
517            Ok(e) => e,
518            Err(_) => continue,
519        };
520        for entry in entries.flatten() {
521            let p = entry.path();
522            if p.extension().and_then(|s| s.to_str()) != Some("toml") {
523                continue;
524            }
525            if let Some(s) = summarize(&p, &pkg) {
526                out.push(s);
527            }
528        }
529    }
530
531    // Sort newest first. card_id embeds a compact UTC timestamp so it's
532    // naturally chronological; we still prefer created_at when present
533    // (some callers may override it), falling back to card_id.
534    out.sort_by(|a, b| {
535        b.created_at
536            .cmp(&a.created_at)
537            .then_with(|| b.card_id.cmp(&a.card_id))
538    });
539    Ok(out)
540}
541
542pub fn summaries_to_json(rows: &[Summary]) -> Json {
543    Json::Array(rows.iter().map(|s| s.to_json()).collect())
544}
545
546// ───────────────────────────────────────────────────────────────
547// P1 API: append / alias_{set,list} / find
548// ───────────────────────────────────────────────────────────────
549
550/// Append new top-level fields to an existing Card.
551///
552/// Semantics: **additive only**. If any top-level key in `fields` already
553/// exists in the Card, the call fails — Cards are immutable w.r.t. existing
554/// data. New top-level keys are inserted and the Card file is rewritten
555/// atomically.
556///
557/// Returns the merged Card JSON.
558pub fn append(card_id: &str, fields: Json) -> Result<Json, String> {
559    let path = find_card_path(card_id)?
560        .ok_or_else(|| format!("alc.card.append: card '{card_id}' not found"))?;
561    let fields_obj = match fields {
562        Json::Object(m) => m,
563        _ => return Err("alc.card.append: fields must be a table".into()),
564    };
565
566    let text =
567        fs::read_to_string(&path).map_err(|e| format!("Failed to read card '{card_id}': {e}"))?;
568    let existing: toml::Value =
569        toml::from_str(&text).map_err(|e| format!("Failed to parse card '{card_id}': {e}"))?;
570    let mut existing_json = toml_to_json(existing);
571    let existing_obj = existing_json
572        .as_object_mut()
573        .ok_or_else(|| format!("Card '{card_id}' is not a table"))?;
574
575    for (k, v) in fields_obj {
576        if existing_obj.contains_key(&k) {
577            return Err(format!(
578                "alc.card.append: key '{k}' already set on card '{card_id}' (immutable)"
579            ));
580        }
581        if !v.is_null() {
582            existing_obj.insert(k, v);
583        }
584    }
585
586    let toml_val = json_to_toml(existing_json.clone())?;
587    let text = toml::to_string_pretty(&toml_val)
588        .map_err(|e| format!("Failed to serialize card TOML: {e}"))?;
589    let tmp = path.with_extension("toml.tmp");
590    fs::write(&tmp, &text).map_err(|e| format!("Failed to write card tmp: {e}"))?;
591    fs::rename(&tmp, &path).map_err(|e| format!("Failed to rename card file: {e}"))?;
592
593    Ok(existing_json)
594}
595
596/// Path of the global alias table: `~/.algocline/cards/_aliases.toml`.
597fn aliases_path() -> Result<PathBuf, String> {
598    Ok(cards_dir()?.join("_aliases.toml"))
599}
600
601#[derive(Debug, Clone)]
602pub struct Alias {
603    pub name: String,
604    pub card_id: String,
605    pub pkg: Option<String>,
606    pub set_at: String,
607    pub note: Option<String>,
608}
609
610impl Alias {
611    fn to_json(&self) -> Json {
612        let mut m = serde_json::Map::new();
613        m.insert("name".into(), json!(self.name));
614        m.insert("card_id".into(), json!(self.card_id));
615        if let Some(p) = &self.pkg {
616            m.insert("pkg".into(), json!(p));
617        }
618        m.insert("set_at".into(), json!(self.set_at));
619        if let Some(n) = &self.note {
620            m.insert("note".into(), json!(n));
621        }
622        Json::Object(m)
623    }
624}
625
626fn read_aliases() -> Result<Vec<Alias>, String> {
627    let path = aliases_path()?;
628    if !path.exists() {
629        return Ok(Vec::new());
630    }
631    let text =
632        fs::read_to_string(&path).map_err(|e| format!("Failed to read aliases file: {e}"))?;
633    let val: toml::Value =
634        toml::from_str(&text).map_err(|e| format!("Failed to parse aliases file: {e}"))?;
635    let arr = val
636        .get("alias")
637        .and_then(|v| v.as_array())
638        .cloned()
639        .unwrap_or_default();
640    let mut out = Vec::with_capacity(arr.len());
641    for entry in arr {
642        let t = match entry {
643            toml::Value::Table(t) => t,
644            _ => continue,
645        };
646        let name = match t.get("name").and_then(|v| v.as_str()) {
647            Some(s) => s.to_string(),
648            None => continue,
649        };
650        let card_id = match t.get("card_id").and_then(|v| v.as_str()) {
651            Some(s) => s.to_string(),
652            None => continue,
653        };
654        out.push(Alias {
655            name,
656            card_id,
657            pkg: t.get("pkg").and_then(|v| v.as_str()).map(String::from),
658            set_at: t
659                .get("set_at")
660                .and_then(|v| v.as_str())
661                .map(String::from)
662                .unwrap_or_default(),
663            note: t.get("note").and_then(|v| v.as_str()).map(String::from),
664        });
665    }
666    Ok(out)
667}
668
669fn write_aliases(aliases: &[Alias]) -> Result<(), String> {
670    let path = aliases_path()?;
671    let mut arr = Vec::with_capacity(aliases.len());
672    for a in aliases {
673        let mut t = toml::map::Map::new();
674        t.insert("name".into(), toml::Value::String(a.name.clone()));
675        t.insert("card_id".into(), toml::Value::String(a.card_id.clone()));
676        if let Some(p) = &a.pkg {
677            t.insert("pkg".into(), toml::Value::String(p.clone()));
678        }
679        t.insert("set_at".into(), toml::Value::String(a.set_at.clone()));
680        if let Some(n) = &a.note {
681            t.insert("note".into(), toml::Value::String(n.clone()));
682        }
683        arr.push(toml::Value::Table(t));
684    }
685    let mut root = toml::map::Map::new();
686    root.insert("alias".into(), toml::Value::Array(arr));
687    let text = toml::to_string_pretty(&toml::Value::Table(root))
688        .map_err(|e| format!("Failed to serialize aliases: {e}"))?;
689    let tmp = path.with_extension("toml.tmp");
690    fs::write(&tmp, &text).map_err(|e| format!("Failed to write aliases tmp: {e}"))?;
691    fs::rename(&tmp, &path).map_err(|e| format!("Failed to rename aliases file: {e}"))?;
692    Ok(())
693}
694
695/// Bind (or rebind) an alias to a Card.
696///
697/// Validates that `card_id` exists. If an alias with the same `name` already
698/// exists it is overwritten — the alias table is intentionally mutable even
699/// though the Cards themselves are not.
700pub fn alias_set(
701    name: &str,
702    card_id: &str,
703    pkg: Option<&str>,
704    note: Option<&str>,
705) -> Result<Alias, String> {
706    validate_name(name, "alias")?;
707    if find_card_path(card_id)?.is_none() {
708        return Err(format!("alc.card.alias_set: card '{card_id}' not found"));
709    }
710    let mut aliases = read_aliases()?;
711    aliases.retain(|a| a.name != name);
712    let entry = Alias {
713        name: name.to_string(),
714        card_id: card_id.to_string(),
715        pkg: pkg.map(String::from),
716        set_at: now_rfc3339(),
717        note: note.map(String::from),
718    };
719    aliases.push(entry.clone());
720    write_aliases(&aliases)?;
721    Ok(entry)
722}
723
724/// Resolve an alias name to its bound Card and return the full Card JSON.
725///
726/// Shortcut for `alias_list → filter → get`. Returns `None` when the alias
727/// does not exist. Errors when the alias points at a missing Card — that
728/// would indicate a corrupt alias table (the target was deleted out of band).
729pub fn get_by_alias(name: &str) -> Result<Option<Json>, String> {
730    validate_name(name, "alias")?;
731    let aliases = read_aliases()?;
732    let Some(alias) = aliases.into_iter().find(|a| a.name == name) else {
733        return Ok(None);
734    };
735    match get(&alias.card_id)? {
736        Some(card) => Ok(Some(card)),
737        None => Err(format!(
738            "alc.card.get_by_alias: alias '{name}' points at missing card '{}'",
739            alias.card_id
740        )),
741    }
742}
743
744/// List aliases, optionally filtered by pkg.
745pub fn alias_list(pkg_filter: Option<&str>) -> Result<Vec<Alias>, String> {
746    let mut aliases = read_aliases()?;
747    if let Some(p) = pkg_filter {
748        aliases.retain(|a| a.pkg.as_deref() == Some(p));
749    }
750    Ok(aliases)
751}
752
753pub fn aliases_to_json(rows: &[Alias]) -> Json {
754    Json::Array(rows.iter().map(|a| a.to_json()).collect())
755}
756
757/// Query parameters for `find`. All filters are optional.
758#[derive(Debug, Default, Clone)]
759pub struct FindQuery {
760    pub pkg: Option<String>,
761    pub scenario: Option<String>,
762    pub model: Option<String>,
763    /// One of: `"pass_rate"` (desc), `"pass_rate_asc"`, `"created_at"` (desc, default).
764    pub sort: Option<String>,
765    pub limit: Option<usize>,
766    pub min_pass_rate: Option<f64>,
767}
768
769/// Filter/sort Cards across the store.
770///
771/// Thin layer over `list`: loads all summaries (optionally restricted to
772/// a pkg subdir), applies field filters, sorts, and truncates.
773pub fn find(q: FindQuery) -> Result<Vec<Summary>, String> {
774    let mut rows = list(q.pkg.as_deref())?;
775    if let Some(s) = &q.scenario {
776        rows.retain(|r| r.scenario.as_deref() == Some(s.as_str()));
777    }
778    if let Some(m) = &q.model {
779        rows.retain(|r| r.model.as_deref() == Some(m.as_str()));
780    }
781    if let Some(min) = q.min_pass_rate {
782        rows.retain(|r| r.pass_rate.is_some_and(|v| v >= min));
783    }
784    match q.sort.as_deref() {
785        Some("pass_rate") => rows.sort_by(|a, b| {
786            b.pass_rate
787                .partial_cmp(&a.pass_rate)
788                .unwrap_or(std::cmp::Ordering::Equal)
789        }),
790        Some("pass_rate_asc") => rows.sort_by(|a, b| {
791            a.pass_rate
792                .partial_cmp(&b.pass_rate)
793                .unwrap_or(std::cmp::Ordering::Equal)
794        }),
795        _ => {
796            rows.sort_by(|a, b| {
797                b.created_at
798                    .cmp(&a.created_at)
799                    .then_with(|| b.card_id.cmp(&a.card_id))
800            });
801        }
802    }
803    if let Some(lim) = q.limit {
804        rows.truncate(lim);
805    }
806    Ok(rows)
807}
808
809// ───────────────────────────────────────────────────────────────
810// Samples sidecar: per-case detail written alongside a Card as
811// `{pkg}/{card_id}.samples.jsonl`. Write-once to preserve Card
812// immutability: once a Card has a samples file, it cannot be
813// rewritten — mismatched per-case data would break auditability.
814// ───────────────────────────────────────────────────────────────
815
816// ───────────────────────────────────────────────────────────────
817// Card import: copy Card files from an external directory into the
818// local cards store. Used by `alc_card_install` (Card Collections)
819// and by `alc_pkg_install` (Pkg-bundled cards/).
820// ───────────────────────────────────────────────────────────────
821
822/// Import Card files from `source_dir` into `~/.algocline/cards/{pkg}/`.
823///
824/// Copies `*.toml` and `*.samples.jsonl` files. Existing cards with the
825/// same id are skipped (first-writer wins — Card immutability).
826///
827/// Returns `(imported, skipped)` card_id lists.
828pub fn import_from_dir(
829    source_dir: &std::path::Path,
830    pkg: &str,
831) -> Result<(Vec<String>, Vec<String>), String> {
832    validate_name(pkg, "pkg")?;
833    let dest = pkg_dir(pkg)?;
834    let mut imported = Vec::new();
835    let mut skipped = Vec::new();
836
837    let entries =
838        fs::read_dir(source_dir).map_err(|e| format!("Failed to read card source dir: {e}"))?;
839
840    for entry in entries.flatten() {
841        let path = entry.path();
842        let fname = match path.file_name().and_then(|n| n.to_str()) {
843            Some(n) => n.to_string(),
844            None => continue,
845        };
846
847        // Only process .toml card files (not .samples.jsonl — those are handled below)
848        if !fname.ends_with(".toml") {
849            continue;
850        }
851
852        let card_id = fname.trim_end_matches(".toml");
853        let dest_toml = dest.join(&fname);
854
855        if dest_toml.exists() {
856            skipped.push(card_id.to_string());
857            continue;
858        }
859
860        // Validate: must contain schema_version = "card/v0"
861        let text = fs::read_to_string(&path)
862            .map_err(|e| format!("Failed to read card file '{fname}': {e}"))?;
863        let val: toml::Value = toml::from_str(&text)
864            .map_err(|e| format!("Failed to parse card file '{fname}': {e}"))?;
865        if val.get("schema_version").and_then(|v| v.as_str()) != Some(SCHEMA_VERSION) {
866            continue; // skip non-card TOML files (e.g. index.toml, _aliases.toml)
867        }
868
869        // Copy .toml
870        fs::copy(&path, &dest_toml).map_err(|e| format!("Failed to copy card '{fname}': {e}"))?;
871
872        // Copy matching .samples.jsonl if present
873        let samples_name = format!("{card_id}.samples.jsonl");
874        let samples_src = source_dir.join(&samples_name);
875        if samples_src.exists() {
876            let samples_dest = dest.join(&samples_name);
877            if !samples_dest.exists() {
878                fs::copy(&samples_src, &samples_dest)
879                    .map_err(|e| format!("Failed to copy samples '{samples_name}': {e}"))?;
880            }
881        }
882
883        imported.push(card_id.to_string());
884    }
885
886    Ok((imported, skipped))
887}
888
889/// Resolve the samples sidecar path for a Card.
890///
891/// Returns an error if the Card does not exist — samples without a
892/// parent Card are meaningless and we refuse to create orphans.
893fn samples_path(card_id: &str) -> Result<PathBuf, String> {
894    let card_path =
895        find_card_path(card_id)?.ok_or_else(|| format!("card '{card_id}' not found"))?;
896    let dir = card_path
897        .parent()
898        .ok_or_else(|| format!("card '{card_id}' has no parent directory"))?;
899    Ok(dir.join(format!("{card_id}.samples.jsonl")))
900}
901
902/// Write per-case samples to `{card_id}.samples.jsonl` (write-once).
903///
904/// Each `samples` entry is serialized as one compact JSON line.
905/// Fails if a samples file already exists for this card — mirrors
906/// the immutability guarantee of Cards themselves.
907pub fn write_samples(card_id: &str, samples: Vec<Json>) -> Result<PathBuf, String> {
908    let path = samples_path(card_id)?;
909    if path.exists() {
910        return Err(format!(
911            "alc.card.write_samples: samples already exist for card '{card_id}' (write-once)"
912        ));
913    }
914    let mut buf = String::new();
915    for (idx, s) in samples.iter().enumerate() {
916        let line = serde_json::to_string(s).map_err(|e| {
917            format!("alc.card.write_samples: failed to serialize sample #{idx}: {e}")
918        })?;
919        buf.push_str(&line);
920        buf.push('\n');
921    }
922    let tmp = path.with_extension("jsonl.tmp");
923    fs::write(&tmp, &buf).map_err(|e| format!("Failed to write samples tmp: {e}"))?;
924    fs::rename(&tmp, &path).map_err(|e| format!("Failed to rename samples file: {e}"))?;
925    Ok(path)
926}
927
928/// Read per-case samples from `{card_id}.samples.jsonl`.
929///
930/// Returns an empty Vec if no samples file exists (Cards without
931/// per-case details are the common case, not an error).
932pub fn read_samples(
933    card_id: &str,
934    offset: usize,
935    limit: Option<usize>,
936) -> Result<Vec<Json>, String> {
937    let path = samples_path(card_id)?;
938    if !path.exists() {
939        return Ok(Vec::new());
940    }
941    let text =
942        fs::read_to_string(&path).map_err(|e| format!("Failed to read samples file: {e}"))?;
943    let mut out = Vec::new();
944    for (i, line) in text.lines().enumerate() {
945        if line.trim().is_empty() {
946            continue;
947        }
948        if i < offset {
949            continue;
950        }
951        if let Some(lim) = limit {
952            if out.len() >= lim {
953                break;
954            }
955        }
956        let val: Json = serde_json::from_str(line)
957            .map_err(|e| format!("Failed to parse sample line {i}: {e}"))?;
958        out.push(val);
959    }
960    Ok(out)
961}
962
963#[cfg(test)]
964mod tests {
965    use super::*;
966
967    fn unique_pkg() -> String {
968        let ns = std::time::SystemTime::now()
969            .duration_since(std::time::UNIX_EPOCH)
970            .unwrap()
971            .as_nanos();
972        format!("_test_card_{ns}")
973    }
974
975    fn cleanup(pkg: &str) {
976        if let Ok(d) = pkg_dir(pkg) {
977            let _ = fs::remove_dir_all(&d);
978        }
979    }
980
981    #[test]
982    fn minimum_valid_card() {
983        let pkg = unique_pkg();
984        let input = json!({ "pkg": { "name": pkg } });
985        let (id, path) = create(input).unwrap();
986        assert!(path.exists());
987        assert!(id.starts_with(&pkg));
988
989        let got = get(&id).unwrap().unwrap();
990        assert_eq!(got["schema_version"], json!(SCHEMA_VERSION));
991        assert_eq!(got["card_id"], json!(id));
992        assert_eq!(got["pkg"]["name"], json!(pkg));
993        assert!(got.get("created_at").is_some());
994        assert!(got.get("created_by").is_some());
995
996        cleanup(&pkg);
997    }
998
999    #[test]
1000    fn create_rejects_missing_pkg_name() {
1001        let err = create(json!({})).unwrap_err();
1002        assert!(err.contains("pkg.name"));
1003    }
1004
1005    #[test]
1006    fn create_is_immutable() {
1007        let pkg = unique_pkg();
1008        let input = json!({
1009            "card_id": "fixed_id_001",
1010            "pkg": { "name": pkg }
1011        });
1012        create(input.clone()).unwrap();
1013        let err = create(input).unwrap_err();
1014        assert!(err.contains("already exists"));
1015        cleanup(&pkg);
1016    }
1017
1018    #[test]
1019    fn create_injects_param_fingerprint() {
1020        let pkg = unique_pkg();
1021        let input = json!({
1022            "pkg": { "name": pkg },
1023            "params": { "depth": 3, "temperature": 0.0 }
1024        });
1025        let (id, _) = create(input).unwrap();
1026        let got = get(&id).unwrap().unwrap();
1027        assert!(got["param_fingerprint"].is_string());
1028        cleanup(&pkg);
1029    }
1030
1031    #[test]
1032    fn list_returns_newest_first() {
1033        let pkg = unique_pkg();
1034        // First card
1035        let (id1, _) = create(json!({
1036            "card_id": format!("{pkg}_a"),
1037            "pkg": { "name": pkg },
1038            "created_at": "2025-01-01T00:00:00Z"
1039        }))
1040        .unwrap();
1041        let (id2, _) = create(json!({
1042            "card_id": format!("{pkg}_b"),
1043            "pkg": { "name": pkg },
1044            "created_at": "2026-01-01T00:00:00Z"
1045        }))
1046        .unwrap();
1047
1048        let rows = list(Some(&pkg)).unwrap();
1049        assert_eq!(rows.len(), 2);
1050        assert_eq!(rows[0].card_id, id2); // newer first
1051        assert_eq!(rows[1].card_id, id1);
1052
1053        cleanup(&pkg);
1054    }
1055
1056    #[test]
1057    fn list_extracts_summary_fields() {
1058        let pkg = unique_pkg();
1059        let (id, _) = create(json!({
1060            "pkg": { "name": pkg },
1061            "model": { "id": "claude-opus-4-6" },
1062            "scenario": { "name": "gsm8k_sample100" },
1063            "stats": { "pass_rate": 0.82 }
1064        }))
1065        .unwrap();
1066
1067        let rows = list(Some(&pkg)).unwrap();
1068        let row = rows.iter().find(|r| r.card_id == id).unwrap();
1069        assert_eq!(row.model.as_deref(), Some("claude-opus-4-6"));
1070        assert_eq!(row.scenario.as_deref(), Some("gsm8k_sample100"));
1071        assert_eq!(row.pass_rate, Some(0.82));
1072
1073        cleanup(&pkg);
1074    }
1075
1076    #[test]
1077    fn get_missing_returns_none() {
1078        assert!(get("does_not_exist_xyz").unwrap().is_none());
1079    }
1080
1081    #[test]
1082    fn card_id_embeds_compact_timestamp() {
1083        let pkg = unique_pkg();
1084        let (id, _) = create(json!({ "pkg": { "name": pkg } })).unwrap();
1085        // Expect: {pkg}_{model}_{YYYYMMDDTHHMMSS}_{hash6}
1086        // After removing the pkg prefix, there should be a segment
1087        // containing 'T' separating date and time.
1088        let tail = id.strip_prefix(&format!("{pkg}_")).unwrap();
1089        let parts: Vec<&str> = tail.split('_').collect();
1090        // parts = [model_short, YYYYMMDDTHHMMSS, hash6]
1091        assert_eq!(parts.len(), 3, "unexpected card_id shape: {id}");
1092        let ts = parts[1];
1093        assert_eq!(ts.len(), 15, "timestamp segment wrong length: {ts}");
1094        assert!(ts.chars().nth(8) == Some('T'), "missing T separator: {ts}");
1095        cleanup(&pkg);
1096    }
1097
1098    #[test]
1099    fn now_compact_format() {
1100        let s = now_compact();
1101        assert_eq!(s.len(), 15);
1102        assert_eq!(s.chars().nth(8), Some('T'));
1103        // All other positions are digits
1104        for (i, c) in s.chars().enumerate() {
1105            if i != 8 {
1106                assert!(c.is_ascii_digit(), "non-digit at pos {i}: {s}");
1107            }
1108        }
1109    }
1110
1111    #[test]
1112    fn short_model_variants() {
1113        assert_eq!(short_model("claude-opus-4-6"), "opus46");
1114        assert_eq!(short_model("gpt-4o"), "4o");
1115        assert_eq!(short_model(""), "model");
1116    }
1117
1118    #[test]
1119    fn two_cards_same_second_different_stats_get_distinct_ids() {
1120        let pkg = unique_pkg();
1121        let input1 = json!({
1122            "pkg": { "name": pkg },
1123            "scenario": { "name": "gsm8k" },
1124            "stats": { "pass_rate": 0.4 }
1125        });
1126        let input2 = json!({
1127            "pkg": { "name": pkg },
1128            "scenario": { "name": "gsm8k" },
1129            "stats": { "pass_rate": 0.9 }
1130        });
1131        let (id1, _) = create(input1).unwrap();
1132        let (id2, _) = create(input2).unwrap();
1133        assert_ne!(id1, id2, "distinct stats must yield distinct card_ids");
1134        cleanup(&pkg);
1135    }
1136
1137    // ─── P1: append ────────────────────────────────────────────
1138
1139    #[test]
1140    fn append_adds_new_fields() {
1141        let pkg = unique_pkg();
1142        let (id, _) = create(json!({
1143            "pkg": { "name": pkg },
1144            "stats": { "pass_rate": 0.5 }
1145        }))
1146        .unwrap();
1147
1148        let merged = append(
1149            &id,
1150            json!({
1151                "caveats": { "notes": "rescored after fix" },
1152                "metadata": { "reviewer": "yn" }
1153            }),
1154        )
1155        .unwrap();
1156        assert_eq!(merged["caveats"]["notes"], json!("rescored after fix"));
1157        assert_eq!(merged["metadata"]["reviewer"], json!("yn"));
1158
1159        // Persisted
1160        let got = get(&id).unwrap().unwrap();
1161        assert_eq!(got["caveats"]["notes"], json!("rescored after fix"));
1162        // Existing field untouched
1163        assert_eq!(got["stats"]["pass_rate"], json!(0.5));
1164
1165        cleanup(&pkg);
1166    }
1167
1168    #[test]
1169    fn append_rejects_existing_key() {
1170        let pkg = unique_pkg();
1171        let (id, _) = create(json!({
1172            "pkg": { "name": pkg },
1173            "stats": { "pass_rate": 0.5 }
1174        }))
1175        .unwrap();
1176
1177        let err = append(&id, json!({ "stats": { "pass_rate": 0.9 } })).unwrap_err();
1178        assert!(err.contains("already set"), "got: {err}");
1179        // Verify original value still there
1180        let got = get(&id).unwrap().unwrap();
1181        assert_eq!(got["stats"]["pass_rate"], json!(0.5));
1182
1183        cleanup(&pkg);
1184    }
1185
1186    #[test]
1187    fn append_errors_on_missing_card() {
1188        let err = append("does_not_exist_xyz", json!({ "x": 1 })).unwrap_err();
1189        assert!(err.contains("not found"));
1190    }
1191
1192    // ─── P1: alias_set / alias_list ────────────────────────────
1193
1194    #[test]
1195    fn alias_set_and_list_roundtrip() {
1196        let pkg = unique_pkg();
1197        let (id, _) = create(json!({ "pkg": { "name": pkg } })).unwrap();
1198
1199        let alias_name = format!("test_alias_{}", &pkg);
1200        alias_set(&alias_name, &id, Some(&pkg), Some("smoke")).unwrap();
1201
1202        let rows = alias_list(Some(&pkg)).unwrap();
1203        let a = rows.iter().find(|a| a.name == alias_name).unwrap();
1204        assert_eq!(a.card_id, id);
1205        assert_eq!(a.pkg.as_deref(), Some(pkg.as_str()));
1206        assert_eq!(a.note.as_deref(), Some("smoke"));
1207        assert!(!a.set_at.is_empty());
1208
1209        // Rebind to a new card
1210        let (id2, _) = create(json!({
1211            "card_id": format!("{pkg}_b"),
1212            "pkg": { "name": pkg }
1213        }))
1214        .unwrap();
1215        alias_set(&alias_name, &id2, Some(&pkg), None).unwrap();
1216        let rows = alias_list(Some(&pkg)).unwrap();
1217        let matching: Vec<&Alias> = rows.iter().filter(|a| a.name == alias_name).collect();
1218        assert_eq!(matching.len(), 1, "alias should be unique by name");
1219        assert_eq!(matching[0].card_id, id2);
1220
1221        // Cleanup: remove our alias from the file
1222        let remaining: Vec<Alias> = read_aliases()
1223            .unwrap()
1224            .into_iter()
1225            .filter(|a| a.name != alias_name)
1226            .collect();
1227        write_aliases(&remaining).unwrap();
1228        cleanup(&pkg);
1229    }
1230
1231    #[test]
1232    fn alias_set_rejects_unknown_card() {
1233        let err = alias_set("x", "does_not_exist_xyz", None, None).unwrap_err();
1234        assert!(err.contains("not found"));
1235    }
1236
1237    // ─── P1: find ──────────────────────────────────────────────
1238
1239    #[test]
1240    fn find_filters_and_sorts_by_pass_rate() {
1241        let pkg = unique_pkg();
1242        create(json!({
1243            "card_id": format!("{pkg}_low"),
1244            "pkg": { "name": pkg },
1245            "scenario": { "name": "gsm8k" },
1246            "stats": { "pass_rate": 0.4 }
1247        }))
1248        .unwrap();
1249        create(json!({
1250            "card_id": format!("{pkg}_high"),
1251            "pkg": { "name": pkg },
1252            "scenario": { "name": "gsm8k" },
1253            "stats": { "pass_rate": 0.9 }
1254        }))
1255        .unwrap();
1256        create(json!({
1257            "card_id": format!("{pkg}_other"),
1258            "pkg": { "name": pkg },
1259            "scenario": { "name": "other" },
1260            "stats": { "pass_rate": 1.0 }
1261        }))
1262        .unwrap();
1263
1264        let rows = find(FindQuery {
1265            pkg: Some(pkg.clone()),
1266            scenario: Some("gsm8k".into()),
1267            sort: Some("pass_rate".into()),
1268            ..Default::default()
1269        })
1270        .unwrap();
1271        assert_eq!(rows.len(), 2);
1272        assert_eq!(rows[0].pass_rate, Some(0.9));
1273        assert_eq!(rows[1].pass_rate, Some(0.4));
1274
1275        // min_pass_rate filter
1276        let rows = find(FindQuery {
1277            pkg: Some(pkg.clone()),
1278            min_pass_rate: Some(0.8),
1279            sort: Some("pass_rate".into()),
1280            ..Default::default()
1281        })
1282        .unwrap();
1283        assert_eq!(rows.len(), 2);
1284        assert!(rows.iter().all(|r| r.pass_rate.unwrap() >= 0.8));
1285
1286        // limit
1287        let rows = find(FindQuery {
1288            pkg: Some(pkg.clone()),
1289            sort: Some("pass_rate".into()),
1290            limit: Some(1),
1291            ..Default::default()
1292        })
1293        .unwrap();
1294        assert_eq!(rows.len(), 1);
1295        assert_eq!(rows[0].pass_rate, Some(1.0));
1296
1297        cleanup(&pkg);
1298    }
1299
1300    // ─── samples sidecar ───────────────────────────────────────
1301
1302    #[test]
1303    fn write_and_read_samples_roundtrip() {
1304        let pkg = unique_pkg();
1305        let (id, _) = create(json!({
1306            "pkg": { "name": pkg },
1307            "stats": { "pass_rate": 0.5 }
1308        }))
1309        .unwrap();
1310
1311        let samples = vec![
1312            json!({ "case": "c0", "passed": true, "score": 1.0 }),
1313            json!({ "case": "c1", "passed": false, "score": 0.0 }),
1314            json!({ "case": "c2", "passed": true, "score": 0.75 }),
1315        ];
1316        let path = write_samples(&id, samples.clone()).unwrap();
1317        assert!(path.exists());
1318        assert!(path.to_string_lossy().ends_with(".samples.jsonl"));
1319
1320        let got = read_samples(&id, 0, None).unwrap();
1321        assert_eq!(got.len(), 3);
1322        assert_eq!(got[0]["case"], json!("c0"));
1323        assert_eq!(got[2]["score"], json!(0.75));
1324
1325        // offset + limit
1326        let slice = read_samples(&id, 1, Some(1)).unwrap();
1327        assert_eq!(slice.len(), 1);
1328        assert_eq!(slice[0]["case"], json!("c1"));
1329
1330        cleanup(&pkg);
1331    }
1332
1333    #[test]
1334    fn write_samples_is_write_once() {
1335        let pkg = unique_pkg();
1336        let (id, _) = create(json!({ "pkg": { "name": pkg } })).unwrap();
1337        write_samples(&id, vec![json!({ "x": 1 })]).unwrap();
1338        let err = write_samples(&id, vec![json!({ "x": 2 })]).unwrap_err();
1339        assert!(err.contains("already exist"), "got: {err}");
1340        cleanup(&pkg);
1341    }
1342
1343    #[test]
1344    fn read_samples_empty_when_absent() {
1345        let pkg = unique_pkg();
1346        let (id, _) = create(json!({ "pkg": { "name": pkg } })).unwrap();
1347        let got = read_samples(&id, 0, None).unwrap();
1348        assert!(got.is_empty());
1349        cleanup(&pkg);
1350    }
1351
1352    #[test]
1353    fn get_by_alias_roundtrip() {
1354        let pkg = unique_pkg();
1355        let (id, _) = create(json!({
1356            "pkg": { "name": pkg },
1357            "stats": { "pass_rate": 0.85 }
1358        }))
1359        .unwrap();
1360
1361        let alias_name = format!("best_{pkg}");
1362        alias_set(&alias_name, &id, Some(&pkg), None).unwrap();
1363
1364        let card = get_by_alias(&alias_name).unwrap().unwrap();
1365        assert_eq!(card["card_id"], json!(id));
1366        assert_eq!(card["stats"]["pass_rate"], json!(0.85));
1367
1368        assert!(get_by_alias("nonexistent_alias_xyz").unwrap().is_none());
1369
1370        cleanup(&pkg);
1371    }
1372
1373    #[test]
1374    fn samples_errors_on_missing_card() {
1375        let err = write_samples("does_not_exist_xyz_samples", vec![json!({})]).unwrap_err();
1376        assert!(err.contains("not found"));
1377    }
1378
1379    // ─── import_from_dir ───────────────────────────────────────
1380
1381    #[test]
1382    fn import_from_dir_copies_cards() {
1383        let pkg = unique_pkg();
1384        let tmp = tempfile::tempdir().unwrap();
1385
1386        // Create a source card file
1387        let card_id = format!("{pkg}_imported");
1388        let card_content = format!(
1389            "schema_version = \"{SCHEMA_VERSION}\"\ncard_id = \"{card_id}\"\npkg = \"{pkg}\"\n"
1390        );
1391        fs::write(tmp.path().join(format!("{card_id}.toml")), &card_content).unwrap();
1392
1393        // Create a matching samples file
1394        fs::write(
1395            tmp.path().join(format!("{card_id}.samples.jsonl")),
1396            "{\"case\":\"c0\"}\n",
1397        )
1398        .unwrap();
1399
1400        let (imported, skipped) = import_from_dir(tmp.path(), &pkg).unwrap();
1401        assert_eq!(imported, vec![card_id.clone()]);
1402        assert!(skipped.is_empty());
1403
1404        // Verify card was imported
1405        let got = get(&card_id).unwrap().unwrap();
1406        assert_eq!(got["card_id"], json!(card_id));
1407
1408        // Verify samples were copied
1409        let samples = read_samples(&card_id, 0, None).unwrap();
1410        assert_eq!(samples.len(), 1);
1411
1412        cleanup(&pkg);
1413    }
1414
1415    #[test]
1416    fn import_from_dir_skips_existing() {
1417        let pkg = unique_pkg();
1418        // Create a card in the store first
1419        let (existing_id, _) = create(json!({
1420            "pkg": { "name": pkg },
1421            "stats": { "pass_rate": 0.5 }
1422        }))
1423        .unwrap();
1424
1425        // Try to import a card with the same id
1426        let tmp = tempfile::tempdir().unwrap();
1427        let card_content = format!(
1428            "schema_version = \"{SCHEMA_VERSION}\"\ncard_id = \"{existing_id}\"\npkg = \"{pkg}\"\n"
1429        );
1430        fs::write(
1431            tmp.path().join(format!("{existing_id}.toml")),
1432            &card_content,
1433        )
1434        .unwrap();
1435
1436        let (imported, skipped) = import_from_dir(tmp.path(), &pkg).unwrap();
1437        assert!(imported.is_empty());
1438        assert_eq!(skipped, vec![existing_id.clone()]);
1439
1440        // Original card untouched
1441        let got = get(&existing_id).unwrap().unwrap();
1442        assert_eq!(got["stats"]["pass_rate"], json!(0.5));
1443
1444        cleanup(&pkg);
1445    }
1446
1447    #[test]
1448    fn import_from_dir_skips_non_card_toml() {
1449        let pkg = unique_pkg();
1450        let tmp = tempfile::tempdir().unwrap();
1451
1452        // A TOML file without schema_version = "card/v0" should be skipped
1453        fs::write(tmp.path().join("not_a_card.toml"), "title = \"hello\"\n").unwrap();
1454
1455        let (imported, skipped) = import_from_dir(tmp.path(), &pkg).unwrap();
1456        assert!(imported.is_empty());
1457        assert!(skipped.is_empty());
1458
1459        cleanup(&pkg);
1460    }
1461}