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//! | `[strategy_params]` | Strategy-tunable parameters surfaced for sweeps / optimizers (e.g. `alpha`, `temperature`, `depth`). Free-form, but `where`-queryable as a first-class section |
63//! | `[scenario]` | `name`, `source`, `case_count`, `grader` |
64//! | `[stats]` | `pass_rate`, `mean_score`, `std`, `median`, `min`, `max`, `n` |
65//! | `[stats.by_bucket]` | Disaggregated sub-bucket stats (array of tables) |
66//! | `[cost]` | `llm_calls`, `input_tokens`, `output_tokens`, `elapsed_ms`, `usd_estimate` |
67//! | `[optimize]` | `target`, `search`, `rounds_used`, `top_k` (for optimize Cards) |
68//! | `[metadata]` | Free-form escape hatch. Recognized lineage conventions: `prior_card_id` (parent Card id), `prior_relation` (relation kind, e.g. `"sweep_variant"`, `"reflection_of"`, `"derived_from"`) |
69//!
70//! ## Lua API (`alc.card.*`)
71//!
72//! | Function | Description |
73//! |----------|-------------|
74//! | `create(table)` | Write new Card (Tier 1). Returns `{ card_id, path }` |
75//! | `get(card_id)` | Read Card by id. Returns table or nil |
76//! | `list(filter?)` | List Cards as summaries (newest first) |
77//! | `find(query?)` | Prisma-style `where` DSL + dotted-path `order_by` + `offset`/`limit` |
78//! | `append(card_id, fields)` | Additive-only annotation (new keys only) |
79//! | `alias_set(name, card_id, opts?)` | Pin mutable alias |
80//! | `alias_list(filter?)` | List aliases |
81//! | `get_by_alias(name)` | Resolve alias → full Card |
82//! | `write_samples(card_id, samples)` | Write Tier 2 sidecar (write-once) |
83//! | `read_samples(card_id, opts?)` | Read Tier 2 with `where` filtering + offset/limit paging |
84//! | `lineage(query)` | Walk ancestry/descendants via `metadata.prior_card_id` |
85
86use std::fs;
87use std::path::PathBuf;
88
89use serde_json::{json, Value as Json};
90
91pub const SCHEMA_VERSION: &str = "card/v0";
92
93/// Resolve the cards root directory, creating it if needed.
94fn cards_dir() -> Result<PathBuf, String> {
95    let home = dirs::home_dir().ok_or("Cannot determine home directory")?;
96    let dir = home.join(".algocline").join("cards");
97    if !dir.exists() {
98        fs::create_dir_all(&dir).map_err(|e| format!("Failed to create cards dir: {e}"))?;
99    }
100    Ok(dir)
101}
102
103/// Per-pkg subdirectory. Validates pkg name to prevent path traversal.
104fn pkg_dir(pkg: &str) -> Result<PathBuf, String> {
105    validate_name(pkg, "pkg")?;
106    let dir = cards_dir()?.join(pkg);
107    if !dir.exists() {
108        fs::create_dir_all(&dir).map_err(|e| format!("Failed to create pkg dir: {e}"))?;
109    }
110    Ok(dir)
111}
112
113fn validate_name(name: &str, kind: &str) -> Result<(), String> {
114    if name.is_empty()
115        || name.contains('/')
116        || name.contains('\\')
117        || name.contains("..")
118        || name.contains('\0')
119    {
120        return Err(format!("Invalid {kind} name: '{name}'"));
121    }
122    Ok(())
123}
124
125/// DJB2 hash, hex-encoded. Used for param_fingerprint and card_id hash segment.
126fn djb2_hex(s: &str) -> String {
127    let mut h: u64 = 5381;
128    for b in s.bytes() {
129        h = h.wrapping_mul(33).wrapping_add(b as u64);
130    }
131    format!("{h:016x}")
132}
133
134/// Short-hash: last 6 hex chars of djb2.
135///
136/// DJB2's high bits are dominated by the `5381 * 33^n` term (same for any
137/// input of equal length), so the top hex digits collide easily for same-
138/// length inputs that differ only in a few byte positions. The low bits,
139/// driven by the most-recent bytes, mix well enough for short-hash use.
140fn hash6(s: &str) -> String {
141    let hex = djb2_hex(s);
142    let start = hex.len().saturating_sub(6);
143    hex[start..].to_string()
144}
145
146/// Stable serialization of a JSON value for hashing (sorted keys).
147fn stable_json(v: &Json) -> String {
148    let mut buf = String::new();
149    stable_json_into(v, &mut buf);
150    buf
151}
152fn stable_json_into(v: &Json, buf: &mut String) {
153    match v {
154        Json::Null => buf.push_str("null"),
155        Json::Bool(b) => buf.push_str(if *b { "true" } else { "false" }),
156        Json::Number(n) => buf.push_str(&n.to_string()),
157        Json::String(s) => {
158            buf.push('"');
159            buf.push_str(s);
160            buf.push('"');
161        }
162        Json::Array(a) => {
163            buf.push('[');
164            for (i, item) in a.iter().enumerate() {
165                if i > 0 {
166                    buf.push(',');
167                }
168                stable_json_into(item, buf);
169            }
170            buf.push(']');
171        }
172        Json::Object(m) => {
173            let mut keys: Vec<&String> = m.keys().collect();
174            keys.sort();
175            buf.push('{');
176            for (i, k) in keys.iter().enumerate() {
177                if i > 0 {
178                    buf.push(',');
179                }
180                buf.push('"');
181                buf.push_str(k);
182                buf.push_str("\":");
183                stable_json_into(&m[*k], buf);
184            }
185            buf.push('}');
186        }
187    }
188}
189
190/// Derive a short model id (e.g. "claude-opus-4-6" -> "opus46").
191/// v0: best-effort. Falls back to "model" if input is empty.
192fn short_model(id: &str) -> String {
193    if id.is_empty() {
194        return "model".into();
195    }
196    // Strip common vendor prefixes.
197    let stripped = id
198        .strip_prefix("claude-")
199        .or_else(|| id.strip_prefix("gpt-"))
200        .unwrap_or(id);
201    // Keep alnum only.
202    let s: String = stripped
203        .chars()
204        .filter(|c| c.is_ascii_alphanumeric())
205        .collect();
206    if s.is_empty() {
207        "model".into()
208    } else {
209        s
210    }
211}
212
213/// RFC3339 UTC "YYYY-MM-DDTHH:MM:SSZ" from current system time.
214fn now_rfc3339() -> String {
215    let secs = std::time::SystemTime::now()
216        .duration_since(std::time::UNIX_EPOCH)
217        .map(|d| d.as_secs())
218        .unwrap_or(0) as i64;
219    let days = secs.div_euclid(86400);
220    let tod = secs.rem_euclid(86400);
221    let (y, mo, d) = civil_from_days(days);
222    let hh = tod / 3600;
223    let mm = (tod % 3600) / 60;
224    let ss = tod % 60;
225    format!("{y:04}-{mo:02}-{d:02}T{hh:02}:{mm:02}:{ss:02}Z")
226}
227
228/// YYYYMMDDTHHMMSS for current UTC time (compact, sortable).
229///
230/// Used in card_id generation so that:
231///   - rapid successive runs don't collide on the hash6 segment
232///   - string sort of card_id = chronological order
233fn now_compact() -> String {
234    let secs = std::time::SystemTime::now()
235        .duration_since(std::time::UNIX_EPOCH)
236        .map(|d| d.as_secs())
237        .unwrap_or(0) as i64;
238    let days = secs.div_euclid(86400);
239    let tod = secs.rem_euclid(86400);
240    let (y, mo, d) = civil_from_days(days);
241    let hh = tod / 3600;
242    let mm = (tod % 3600) / 60;
243    let ss = tod % 60;
244    format!("{y:04}{mo:02}{d:02}T{hh:02}{mm:02}{ss:02}")
245}
246
247/// Howard Hinnant's civil_from_days algorithm.
248fn civil_from_days(z: i64) -> (i32, u32, u32) {
249    let z = z + 719468;
250    let era = if z >= 0 { z } else { z - 146096 } / 146097;
251    let doe = (z - era * 146097) as u64;
252    let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365;
253    let y = yoe as i64 + era * 400;
254    let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
255    let mp = (5 * doy + 2) / 153;
256    let d = (doy - (153 * mp + 2) / 5 + 1) as u32;
257    let m = (if mp < 10 { mp + 3 } else { mp - 9 }) as u32;
258    let y = y + if m <= 2 { 1 } else { 0 };
259    (y as i32, m, d)
260}
261
262/// Converter: serde_json::Value -> toml::Value.
263/// Nulls are dropped (TOML has no null). Mixed-type arrays are allowed in TOML 1.0+.
264fn json_to_toml(v: Json) -> Result<toml::Value, String> {
265    Ok(match v {
266        Json::Null => return Err("TOML does not support null values".into()),
267        Json::Bool(b) => toml::Value::Boolean(b),
268        Json::Number(n) => {
269            if let Some(i) = n.as_i64() {
270                toml::Value::Integer(i)
271            } else if let Some(f) = n.as_f64() {
272                toml::Value::Float(f)
273            } else {
274                return Err(format!("Unsupported number: {n}"));
275            }
276        }
277        Json::String(s) => toml::Value::String(s),
278        Json::Array(a) => {
279            let mut out = Vec::with_capacity(a.len());
280            for item in a {
281                if !item.is_null() {
282                    out.push(json_to_toml(item)?);
283                }
284            }
285            toml::Value::Array(out)
286        }
287        Json::Object(m) => {
288            let mut table = toml::map::Map::new();
289            for (k, val) in m {
290                if val.is_null() {
291                    continue;
292                }
293                table.insert(k, json_to_toml(val)?);
294            }
295            toml::Value::Table(table)
296        }
297    })
298}
299
300/// Converter: toml::Value -> serde_json::Value (for alc.card.get()).
301fn toml_to_json(v: toml::Value) -> Json {
302    match v {
303        toml::Value::String(s) => Json::String(s),
304        toml::Value::Integer(i) => json!(i),
305        toml::Value::Float(f) => json!(f),
306        toml::Value::Boolean(b) => Json::Bool(b),
307        toml::Value::Datetime(dt) => Json::String(dt.to_string()),
308        toml::Value::Array(a) => Json::Array(a.into_iter().map(toml_to_json).collect()),
309        toml::Value::Table(t) => {
310            let mut m = serde_json::Map::new();
311            for (k, v) in t {
312                m.insert(k, toml_to_json(v));
313            }
314            Json::Object(m)
315        }
316    }
317}
318
319/// Extract [pkg].name from an input JSON object. REQUIRED.
320fn require_pkg_name(input: &Json) -> Result<String, String> {
321    let name = input
322        .get("pkg")
323        .and_then(|p| p.get("name"))
324        .and_then(|n| n.as_str())
325        .ok_or_else(|| "alc.card.create: pkg.name is required".to_string())?
326        .to_string();
327    validate_name(&name, "pkg")?;
328    Ok(name)
329}
330
331/// Main create entry. Returns (card_id, absolute_path).
332pub fn create(mut input: Json) -> Result<(String, PathBuf), String> {
333    if !input.is_object() {
334        return Err("alc.card.create: input must be a table".into());
335    }
336    let pkg_name = require_pkg_name(&input)?;
337    let obj = input.as_object_mut().unwrap();
338
339    // ─── Auto-inject REQUIRED fields ──────────────────────────
340    obj.entry("schema_version".to_string())
341        .or_insert_with(|| json!(SCHEMA_VERSION));
342    obj.entry("created_at".to_string())
343        .or_insert_with(|| json!(now_rfc3339()));
344    obj.entry("created_by".to_string())
345        .or_insert_with(|| json!(format!("alc@{}", env!("CARGO_PKG_VERSION"))));
346
347    // ─── param_fingerprint (if [params] present) ──────────────
348    if let Some(params) = obj.get("params").cloned() {
349        if params.is_object() {
350            let fp = djb2_hex(&stable_json(&params));
351            obj.insert("param_fingerprint".to_string(), json!(fp));
352        }
353    }
354
355    // ─── card_id generation (if absent) ───────────────────────
356    let card_id = match obj.get("card_id").and_then(|v| v.as_str()) {
357        Some(id) if !id.is_empty() => id.to_string(),
358        _ => {
359            let model_id = obj
360                .get("model")
361                .and_then(|m| m.get("id"))
362                .and_then(|v| v.as_str())
363                .unwrap_or("");
364            let model_short = short_model(model_id);
365            let ts = now_compact();
366            let fp_seed = stable_json(&Json::Object(obj.clone()));
367            let h = hash6(&fp_seed);
368            format!("{pkg_name}_{model_short}_{ts}_{h}")
369        }
370    };
371    validate_name(&card_id, "card_id")?;
372    obj.insert("card_id".to_string(), json!(card_id.clone()));
373
374    // ─── Write TOML atomically ────────────────────────────────
375    let dir = pkg_dir(&pkg_name)?;
376    let path = dir.join(format!("{card_id}.toml"));
377    if path.exists() {
378        return Err(format!(
379            "alc.card.create: card '{card_id}' already exists (immutable)"
380        ));
381    }
382    let toml_val = json_to_toml(input)?;
383    let text = toml::to_string_pretty(&toml_val)
384        .map_err(|e| format!("Failed to serialize card TOML: {e}"))?;
385    let tmp = path.with_extension("toml.tmp");
386    fs::write(&tmp, &text).map_err(|e| format!("Failed to write card tmp: {e}"))?;
387    fs::rename(&tmp, &path).map_err(|e| format!("Failed to rename card file: {e}"))?;
388
389    Ok((card_id, path))
390}
391
392/// Search cards dir for `{card_id}.toml`.
393fn find_card_path(card_id: &str) -> Result<Option<PathBuf>, String> {
394    validate_name(card_id, "card_id")?;
395    let root = cards_dir()?;
396    let entries = fs::read_dir(&root).map_err(|e| format!("Failed to read cards dir: {e}"))?;
397    for entry in entries.flatten() {
398        let p = entry.path();
399        if p.is_dir() {
400            let candidate = p.join(format!("{card_id}.toml"));
401            if candidate.exists() {
402                return Ok(Some(candidate));
403            }
404        }
405    }
406    Ok(None)
407}
408
409/// Read a Card by id. Returns None if not found.
410pub fn get(card_id: &str) -> Result<Option<Json>, String> {
411    let path = match find_card_path(card_id)? {
412        Some(p) => p,
413        None => return Ok(None),
414    };
415    let text =
416        fs::read_to_string(&path).map_err(|e| format!("Failed to read card '{card_id}': {e}"))?;
417    let val: toml::Value =
418        toml::from_str(&text).map_err(|e| format!("Failed to parse card '{card_id}': {e}"))?;
419    Ok(Some(toml_to_json(val)))
420}
421
422/// Summary row for `alc.card.list()`.
423#[derive(Debug, Clone)]
424pub struct Summary {
425    pub card_id: String,
426    pub pkg: String,
427    pub created_at: Option<String>,
428    pub model: Option<String>,
429    pub scenario: Option<String>,
430    pub pass_rate: Option<f64>,
431}
432
433impl Summary {
434    fn to_json(&self) -> Json {
435        let mut m = serde_json::Map::new();
436        m.insert("card_id".into(), json!(self.card_id));
437        m.insert("pkg".into(), json!(self.pkg));
438        if let Some(v) = &self.created_at {
439            m.insert("created_at".into(), json!(v));
440        }
441        if let Some(v) = &self.model {
442            m.insert("model".into(), json!(v));
443        }
444        if let Some(v) = &self.scenario {
445            m.insert("scenario".into(), json!(v));
446        }
447        if let Some(v) = self.pass_rate {
448            m.insert("pass_rate".into(), json!(v));
449        }
450        Json::Object(m)
451    }
452}
453
454fn summarize(path: &std::path::Path, pkg: &str) -> Option<Summary> {
455    let text = fs::read_to_string(path).ok()?;
456    let val: toml::Value = toml::from_str(&text).ok()?;
457    let card_id = val
458        .get("card_id")
459        .and_then(|v| v.as_str())
460        .or_else(|| path.file_stem().and_then(|s| s.to_str()))?
461        .to_string();
462    let created_at = val
463        .get("created_at")
464        .and_then(|v| v.as_str())
465        .map(String::from);
466    let model = val
467        .get("model")
468        .and_then(|m| m.get("id"))
469        .and_then(|v| v.as_str())
470        .map(String::from);
471    let scenario = val
472        .get("scenario")
473        .and_then(|s| s.get("name"))
474        .and_then(|v| v.as_str())
475        .map(String::from);
476    let pass_rate = val
477        .get("stats")
478        .and_then(|s| s.get("pass_rate"))
479        .and_then(|v| v.as_float());
480    Some(Summary {
481        card_id,
482        pkg: pkg.to_string(),
483        created_at,
484        model,
485        scenario,
486        pass_rate,
487    })
488}
489
490/// List cards. `pkg_filter = Some("name")` restricts to that pkg subdir.
491pub fn list(pkg_filter: Option<&str>) -> Result<Vec<Summary>, String> {
492    let root = cards_dir()?;
493    let mut out = Vec::new();
494
495    let pkg_dirs: Vec<PathBuf> = if let Some(p) = pkg_filter {
496        validate_name(p, "pkg")?;
497        let d = root.join(p);
498        if d.is_dir() {
499            vec![d]
500        } else {
501            vec![]
502        }
503    } else {
504        fs::read_dir(&root)
505            .map_err(|e| format!("Failed to read cards dir: {e}"))?
506            .flatten()
507            .map(|e| e.path())
508            .filter(|p| p.is_dir())
509            .collect()
510    };
511
512    for pdir in pkg_dirs {
513        let pkg = pdir
514            .file_name()
515            .and_then(|s| s.to_str())
516            .unwrap_or("")
517            .to_string();
518        let entries = match fs::read_dir(&pdir) {
519            Ok(e) => e,
520            Err(_) => continue,
521        };
522        for entry in entries.flatten() {
523            let p = entry.path();
524            if p.extension().and_then(|s| s.to_str()) != Some("toml") {
525                continue;
526            }
527            if let Some(s) = summarize(&p, &pkg) {
528                out.push(s);
529            }
530        }
531    }
532
533    // Sort newest first. card_id embeds a compact UTC timestamp so it's
534    // naturally chronological; we still prefer created_at when present
535    // (some callers may override it), falling back to card_id.
536    out.sort_by(|a, b| {
537        b.created_at
538            .cmp(&a.created_at)
539            .then_with(|| b.card_id.cmp(&a.card_id))
540    });
541    Ok(out)
542}
543
544pub fn summaries_to_json(rows: &[Summary]) -> Json {
545    Json::Array(rows.iter().map(|s| s.to_json()).collect())
546}
547
548// ───────────────────────────────────────────────────────────────
549// P1 API: append / alias_{set,list} / find
550// ───────────────────────────────────────────────────────────────
551
552/// Append new top-level fields to an existing Card.
553///
554/// Semantics: **additive only**. If any top-level key in `fields` already
555/// exists in the Card, the call fails — Cards are immutable w.r.t. existing
556/// data. New top-level keys are inserted and the Card file is rewritten
557/// atomically.
558///
559/// Returns the merged Card JSON.
560pub fn append(card_id: &str, fields: Json) -> Result<Json, String> {
561    let path = find_card_path(card_id)?
562        .ok_or_else(|| format!("alc.card.append: card '{card_id}' not found"))?;
563    let fields_obj = match fields {
564        Json::Object(m) => m,
565        _ => return Err("alc.card.append: fields must be a table".into()),
566    };
567
568    let text =
569        fs::read_to_string(&path).map_err(|e| format!("Failed to read card '{card_id}': {e}"))?;
570    let existing: toml::Value =
571        toml::from_str(&text).map_err(|e| format!("Failed to parse card '{card_id}': {e}"))?;
572    let mut existing_json = toml_to_json(existing);
573    let existing_obj = existing_json
574        .as_object_mut()
575        .ok_or_else(|| format!("Card '{card_id}' is not a table"))?;
576
577    for (k, v) in fields_obj {
578        if existing_obj.contains_key(&k) {
579            return Err(format!(
580                "alc.card.append: key '{k}' already set on card '{card_id}' (immutable)"
581            ));
582        }
583        if !v.is_null() {
584            existing_obj.insert(k, v);
585        }
586    }
587
588    let toml_val = json_to_toml(existing_json.clone())?;
589    let text = toml::to_string_pretty(&toml_val)
590        .map_err(|e| format!("Failed to serialize card TOML: {e}"))?;
591    let tmp = path.with_extension("toml.tmp");
592    fs::write(&tmp, &text).map_err(|e| format!("Failed to write card tmp: {e}"))?;
593    fs::rename(&tmp, &path).map_err(|e| format!("Failed to rename card file: {e}"))?;
594
595    Ok(existing_json)
596}
597
598/// Path of the global alias table: `~/.algocline/cards/_aliases.toml`.
599fn aliases_path() -> Result<PathBuf, String> {
600    Ok(cards_dir()?.join("_aliases.toml"))
601}
602
603#[derive(Debug, Clone)]
604pub struct Alias {
605    pub name: String,
606    pub card_id: String,
607    pub pkg: Option<String>,
608    pub set_at: String,
609    pub note: Option<String>,
610}
611
612impl Alias {
613    fn to_json(&self) -> Json {
614        let mut m = serde_json::Map::new();
615        m.insert("name".into(), json!(self.name));
616        m.insert("card_id".into(), json!(self.card_id));
617        if let Some(p) = &self.pkg {
618            m.insert("pkg".into(), json!(p));
619        }
620        m.insert("set_at".into(), json!(self.set_at));
621        if let Some(n) = &self.note {
622            m.insert("note".into(), json!(n));
623        }
624        Json::Object(m)
625    }
626}
627
628fn read_aliases() -> Result<Vec<Alias>, String> {
629    let path = aliases_path()?;
630    if !path.exists() {
631        return Ok(Vec::new());
632    }
633    let text =
634        fs::read_to_string(&path).map_err(|e| format!("Failed to read aliases file: {e}"))?;
635    let val: toml::Value =
636        toml::from_str(&text).map_err(|e| format!("Failed to parse aliases file: {e}"))?;
637    let arr = val
638        .get("alias")
639        .and_then(|v| v.as_array())
640        .cloned()
641        .unwrap_or_default();
642    let mut out = Vec::with_capacity(arr.len());
643    for entry in arr {
644        let t = match entry {
645            toml::Value::Table(t) => t,
646            _ => continue,
647        };
648        let name = match t.get("name").and_then(|v| v.as_str()) {
649            Some(s) => s.to_string(),
650            None => continue,
651        };
652        let card_id = match t.get("card_id").and_then(|v| v.as_str()) {
653            Some(s) => s.to_string(),
654            None => continue,
655        };
656        out.push(Alias {
657            name,
658            card_id,
659            pkg: t.get("pkg").and_then(|v| v.as_str()).map(String::from),
660            set_at: t
661                .get("set_at")
662                .and_then(|v| v.as_str())
663                .map(String::from)
664                .unwrap_or_default(),
665            note: t.get("note").and_then(|v| v.as_str()).map(String::from),
666        });
667    }
668    Ok(out)
669}
670
671fn write_aliases(aliases: &[Alias]) -> Result<(), String> {
672    let path = aliases_path()?;
673    let mut arr = Vec::with_capacity(aliases.len());
674    for a in aliases {
675        let mut t = toml::map::Map::new();
676        t.insert("name".into(), toml::Value::String(a.name.clone()));
677        t.insert("card_id".into(), toml::Value::String(a.card_id.clone()));
678        if let Some(p) = &a.pkg {
679            t.insert("pkg".into(), toml::Value::String(p.clone()));
680        }
681        t.insert("set_at".into(), toml::Value::String(a.set_at.clone()));
682        if let Some(n) = &a.note {
683            t.insert("note".into(), toml::Value::String(n.clone()));
684        }
685        arr.push(toml::Value::Table(t));
686    }
687    let mut root = toml::map::Map::new();
688    root.insert("alias".into(), toml::Value::Array(arr));
689    let text = toml::to_string_pretty(&toml::Value::Table(root))
690        .map_err(|e| format!("Failed to serialize aliases: {e}"))?;
691    let tmp = path.with_extension("toml.tmp");
692    fs::write(&tmp, &text).map_err(|e| format!("Failed to write aliases tmp: {e}"))?;
693    fs::rename(&tmp, &path).map_err(|e| format!("Failed to rename aliases file: {e}"))?;
694    Ok(())
695}
696
697/// Bind (or rebind) an alias to a Card.
698///
699/// Validates that `card_id` exists. If an alias with the same `name` already
700/// exists it is overwritten — the alias table is intentionally mutable even
701/// though the Cards themselves are not.
702pub fn alias_set(
703    name: &str,
704    card_id: &str,
705    pkg: Option<&str>,
706    note: Option<&str>,
707) -> Result<Alias, String> {
708    validate_name(name, "alias")?;
709    if find_card_path(card_id)?.is_none() {
710        return Err(format!("alc.card.alias_set: card '{card_id}' not found"));
711    }
712    let mut aliases = read_aliases()?;
713    aliases.retain(|a| a.name != name);
714    let entry = Alias {
715        name: name.to_string(),
716        card_id: card_id.to_string(),
717        pkg: pkg.map(String::from),
718        set_at: now_rfc3339(),
719        note: note.map(String::from),
720    };
721    aliases.push(entry.clone());
722    write_aliases(&aliases)?;
723    Ok(entry)
724}
725
726/// Resolve an alias name to its bound Card and return the full Card JSON.
727///
728/// Shortcut for `alias_list → filter → get`. Returns `None` when the alias
729/// does not exist. Errors when the alias points at a missing Card — that
730/// would indicate a corrupt alias table (the target was deleted out of band).
731pub fn get_by_alias(name: &str) -> Result<Option<Json>, String> {
732    validate_name(name, "alias")?;
733    let aliases = read_aliases()?;
734    let Some(alias) = aliases.into_iter().find(|a| a.name == name) else {
735        return Ok(None);
736    };
737    match get(&alias.card_id)? {
738        Some(card) => Ok(Some(card)),
739        None => Err(format!(
740            "alc.card.get_by_alias: alias '{name}' points at missing card '{}'",
741            alias.card_id
742        )),
743    }
744}
745
746/// List aliases, optionally filtered by pkg.
747pub fn alias_list(pkg_filter: Option<&str>) -> Result<Vec<Alias>, String> {
748    let mut aliases = read_aliases()?;
749    if let Some(p) = pkg_filter {
750        aliases.retain(|a| a.pkg.as_deref() == Some(p));
751    }
752    Ok(aliases)
753}
754
755pub fn aliases_to_json(rows: &[Alias]) -> Json {
756    Json::Array(rows.iter().map(|a| a.to_json()).collect())
757}
758
759// ═══════════════════════════════════════════════════════════════
760// Where DSL — Prisma/Mongo-style nested predicates
761// ═══════════════════════════════════════════════════════════════
762//
763// See `workspace/tasks/card-dsl/design.md` for the full spec.
764//
765// Syntax (JSON form, as received from Lua / MCP):
766//
767//   where = {
768//     pkg: "cot",                                      // implicit eq
769//     stats: { pass_rate: { gte: 0.8 }, n: { gte: 30 } },
770//     strategy_params: { temperature: { gte: 0.7 } },
771//     prior_card_id: { exists: true },
772//     _or: [ {...}, {...} ],                           // logical ops
773//     _not: { model: { id: "claude-haiku-4-5-20251001" } },
774//   }
775//
776// Semantics:
777//   * Multiple keys in the same object → implicit AND.
778//   * Nested object value → section (path extension).
779//   * Object whose every key is a reserved operator name → leaf operator
780//     object; applies the operators to the value at the current path.
781//   * Scalar/array value → implicit eq.
782//   * Reserved logical keys: `_and` / `_or` / `_not`.
783//   * Reserved operator keys: `eq ne lt lte gt gte in nin exists
784//     contains starts_with`.  Card schemas must not use these names as
785//     field names under any section.
786//
787// Missing-field comparison:
788//   * `eq/lt/lte/gt/gte/in/contains/starts_with` → false on missing
789//   * `ne/nin`                                   → true  on missing
790//   * `exists`                                   → explicit
791//
792
793/// Single comparison operator.
794#[derive(Debug, Clone, PartialEq)]
795pub enum CmpOp {
796    Eq,
797    Ne,
798    Lt,
799    Lte,
800    Gt,
801    Gte,
802    In,
803    Nin,
804    Exists,
805    Contains,
806    StartsWith,
807}
808
809impl CmpOp {
810    fn from_key(k: &str) -> Option<Self> {
811        Some(match k {
812            "eq" => Self::Eq,
813            "ne" => Self::Ne,
814            "lt" => Self::Lt,
815            "lte" => Self::Lte,
816            "gt" => Self::Gt,
817            "gte" => Self::Gte,
818            "in" => Self::In,
819            "nin" => Self::Nin,
820            "exists" => Self::Exists,
821            "contains" => Self::Contains,
822            "starts_with" => Self::StartsWith,
823            _ => return None,
824        })
825    }
826}
827
828/// One parsed comparison: `path` points at a nested field,
829/// `op` + `value` describe how to compare it.
830#[derive(Debug, Clone)]
831pub struct Comparison {
832    pub path: Vec<String>,
833    pub op: CmpOp,
834    pub value: Json,
835}
836
837/// Parsed predicate tree.
838#[derive(Debug, Clone)]
839pub enum Predicate {
840    And(Vec<Predicate>),
841    Or(Vec<Predicate>),
842    Not(Box<Predicate>),
843    Cmp(Comparison),
844}
845
846/// Is `obj` entirely composed of reserved operator keys?
847/// Empty objects return false (meaningless as an operator object).
848fn is_operator_object(obj: &serde_json::Map<String, Json>) -> bool {
849    if obj.is_empty() {
850        return false;
851    }
852    obj.keys().all(|k| CmpOp::from_key(k).is_some())
853}
854
855/// Parse a `where` JSON value into a `Predicate`.
856///
857/// `prefix` is the current nested-key path as we descend through
858/// section objects.
859pub fn parse_where(value: &Json) -> Result<Predicate, String> {
860    parse_predicate(value, &[])
861}
862
863fn parse_predicate(value: &Json, prefix: &[String]) -> Result<Predicate, String> {
864    let obj = value
865        .as_object()
866        .ok_or_else(|| "where clause must be a table".to_string())?;
867
868    let mut clauses: Vec<Predicate> = Vec::new();
869
870    for (key, val) in obj {
871        match key.as_str() {
872            "_and" => {
873                let arr = val
874                    .as_array()
875                    .ok_or_else(|| "_and must be an array of sub-predicates".to_string())?;
876                let mut subs = Vec::with_capacity(arr.len());
877                for sub in arr {
878                    subs.push(parse_predicate(sub, prefix)?);
879                }
880                clauses.push(Predicate::And(subs));
881            }
882            "_or" => {
883                let arr = val
884                    .as_array()
885                    .ok_or_else(|| "_or must be an array of sub-predicates".to_string())?;
886                let mut subs = Vec::with_capacity(arr.len());
887                for sub in arr {
888                    subs.push(parse_predicate(sub, prefix)?);
889                }
890                clauses.push(Predicate::Or(subs));
891            }
892            "_not" => {
893                clauses.push(Predicate::Not(Box::new(parse_predicate(val, prefix)?)));
894            }
895            _ => {
896                // Field key — extend the current path.
897                let mut new_path = prefix.to_vec();
898                new_path.push(key.clone());
899
900                match val {
901                    Json::Object(m) if is_operator_object(m) => {
902                        // Leaf: operator object at this path.
903                        for (op_key, op_val) in m {
904                            let op = CmpOp::from_key(op_key).expect("validated above");
905                            clauses.push(Predicate::Cmp(Comparison {
906                                path: new_path.clone(),
907                                op,
908                                value: op_val.clone(),
909                            }));
910                        }
911                    }
912                    Json::Object(_) => {
913                        // Nested section: recurse with extended path.
914                        clauses.push(parse_predicate(val, &new_path)?);
915                    }
916                    _ => {
917                        // Scalar/array: implicit eq.
918                        clauses.push(Predicate::Cmp(Comparison {
919                            path: new_path,
920                            op: CmpOp::Eq,
921                            value: val.clone(),
922                        }));
923                    }
924                }
925            }
926        }
927    }
928
929    if clauses.len() == 1 {
930        Ok(clauses.remove(0))
931    } else {
932        Ok(Predicate::And(clauses))
933    }
934}
935
936/// Fetch a nested value from a Card JSON by dotted path.
937fn fetch_path<'a>(card: &'a Json, path: &[String]) -> Option<&'a Json> {
938    let mut node = card;
939    for key in path {
940        let obj = node.as_object()?;
941        node = obj.get(key)?;
942    }
943    Some(node)
944}
945
946/// Compare two JSON scalars with a numeric/string/bool comparator.
947/// Returns None when the types aren't comparable.
948fn json_cmp(a: &Json, b: &Json) -> Option<std::cmp::Ordering> {
949    match (a, b) {
950        (Json::Number(x), Json::Number(y)) => {
951            let xf = x.as_f64()?;
952            let yf = y.as_f64()?;
953            xf.partial_cmp(&yf)
954        }
955        (Json::String(x), Json::String(y)) => Some(x.cmp(y)),
956        (Json::Bool(x), Json::Bool(y)) => Some(x.cmp(y)),
957        _ => None,
958    }
959}
960
961fn json_eq(a: &Json, b: &Json) -> bool {
962    match (a, b) {
963        (Json::Number(x), Json::Number(y)) => match (x.as_f64(), y.as_f64()) {
964            (Some(xf), Some(yf)) => xf == yf,
965            _ => a == b,
966        },
967        _ => a == b,
968    }
969}
970
971fn eval_cmp(cmp: &Comparison, card: &Json) -> bool {
972    let actual = fetch_path(card, &cmp.path);
973    let exists = actual.is_some();
974
975    match cmp.op {
976        CmpOp::Exists => {
977            let want = cmp.value.as_bool().unwrap_or(true);
978            exists == want
979        }
980        CmpOp::Ne => match actual {
981            None => true,
982            Some(v) => !json_eq(v, &cmp.value),
983        },
984        CmpOp::Nin => match actual {
985            None => true,
986            Some(v) => match cmp.value.as_array() {
987                Some(arr) => !arr.iter().any(|e| json_eq(e, v)),
988                None => false,
989            },
990        },
991        CmpOp::Eq => actual.is_some_and(|v| json_eq(v, &cmp.value)),
992        CmpOp::In => actual.is_some_and(|v| match cmp.value.as_array() {
993            Some(arr) => arr.iter().any(|e| json_eq(e, v)),
994            None => false,
995        }),
996        CmpOp::Lt | CmpOp::Lte | CmpOp::Gt | CmpOp::Gte => {
997            let Some(v) = actual else { return false };
998            let Some(ord) = json_cmp(v, &cmp.value) else {
999                return false;
1000            };
1001            use std::cmp::Ordering::{Equal, Greater, Less};
1002            matches!(
1003                (&cmp.op, ord),
1004                (CmpOp::Lt, Less)
1005                    | (CmpOp::Lte, Less | Equal)
1006                    | (CmpOp::Gt, Greater)
1007                    | (CmpOp::Gte, Greater | Equal)
1008            )
1009        }
1010        CmpOp::Contains => {
1011            let Some(Json::String(haystack)) = actual else {
1012                return false;
1013            };
1014            let Some(needle) = cmp.value.as_str() else {
1015                return false;
1016            };
1017            haystack.contains(needle)
1018        }
1019        CmpOp::StartsWith => {
1020            let Some(Json::String(haystack)) = actual else {
1021                return false;
1022            };
1023            let Some(needle) = cmp.value.as_str() else {
1024                return false;
1025            };
1026            haystack.starts_with(needle)
1027        }
1028    }
1029}
1030
1031/// Evaluate a predicate tree against a full Card JSON.
1032pub fn eval_predicate(pred: &Predicate, card: &Json) -> bool {
1033    match pred {
1034        Predicate::And(subs) => subs.iter().all(|p| eval_predicate(p, card)),
1035        Predicate::Or(subs) => subs.iter().any(|p| eval_predicate(p, card)),
1036        Predicate::Not(sub) => !eval_predicate(sub, card),
1037        Predicate::Cmp(c) => eval_cmp(c, card),
1038    }
1039}
1040
1041// ───────────────────────────────────────────────────────────────
1042// Order-by
1043// ───────────────────────────────────────────────────────────────
1044
1045/// Parsed sort key: path with optional descending flag.
1046#[derive(Debug, Clone)]
1047pub struct OrderKey {
1048    pub path: Vec<String>,
1049    pub desc: bool,
1050}
1051
1052impl OrderKey {
1053    fn parse(raw: &str) -> Result<Self, String> {
1054        if raw.is_empty() {
1055            return Err("order_by key must not be empty".into());
1056        }
1057        let (desc, rest) = if let Some(r) = raw.strip_prefix('-') {
1058            (true, r)
1059        } else {
1060            (false, raw)
1061        };
1062        let path: Vec<String> = rest.split('.').map(|s| s.to_string()).collect();
1063        if path.iter().any(|p| p.is_empty()) {
1064            return Err(format!("invalid order_by key: '{raw}'"));
1065        }
1066        Ok(Self { path, desc })
1067    }
1068}
1069
1070/// Parse an order_by JSON value.  Accepts:
1071///   - a string: `"stats.pass_rate"` or `"-stats.pass_rate"`
1072///   - an array of strings: `["-stats.pass_rate", "created_at"]`
1073pub fn parse_order_by(value: &Json) -> Result<Vec<OrderKey>, String> {
1074    match value {
1075        Json::String(s) => Ok(vec![OrderKey::parse(s)?]),
1076        Json::Array(arr) => {
1077            let mut out = Vec::with_capacity(arr.len());
1078            for v in arr {
1079                let s = v
1080                    .as_str()
1081                    .ok_or_else(|| "order_by array must contain strings".to_string())?;
1082                out.push(OrderKey::parse(s)?);
1083            }
1084            Ok(out)
1085        }
1086        _ => Err("order_by must be a string or array of strings".into()),
1087    }
1088}
1089
1090/// Query parameters for `find`.
1091#[derive(Debug, Default, Clone)]
1092pub struct FindQuery {
1093    /// Restrict scan to a single pkg subdir (I/O optimization).
1094    pub pkg: Option<String>,
1095    /// Prisma-style predicate tree.
1096    pub where_: Option<Predicate>,
1097    /// Sort keys (dotted paths, optional `-` prefix for desc).
1098    pub order_by: Vec<OrderKey>,
1099    pub limit: Option<usize>,
1100    pub offset: Option<usize>,
1101}
1102
1103/// A loaded Card row flowing through the find() pipeline.
1104///
1105/// `full` is the whole Card JSON (used by `where` evaluation and
1106/// `order_by` dotted-path lookup); `summary` is the projection
1107/// returned to callers.
1108#[derive(Debug, Clone)]
1109struct CardRow {
1110    full: Json,
1111    summary: Summary,
1112}
1113
1114/// Load a single Card file into a `CardRow`.
1115fn load_full(path: &std::path::Path, pkg: &str) -> Option<CardRow> {
1116    let text = fs::read_to_string(path).ok()?;
1117    let val: toml::Value = toml::from_str(&text).ok()?;
1118    let json = toml_to_json(val);
1119
1120    let card_id = json
1121        .get("card_id")
1122        .and_then(|v| v.as_str())
1123        .or_else(|| path.file_stem().and_then(|s| s.to_str()))?
1124        .to_string();
1125    let created_at = json
1126        .get("created_at")
1127        .and_then(|v| v.as_str())
1128        .map(String::from);
1129    let model = json
1130        .get("model")
1131        .and_then(|m| m.get("id"))
1132        .and_then(|v| v.as_str())
1133        .map(String::from);
1134    let scenario = json
1135        .get("scenario")
1136        .and_then(|s| s.get("name"))
1137        .and_then(|v| v.as_str())
1138        .map(String::from);
1139    let pass_rate = json
1140        .get("stats")
1141        .and_then(|s| s.get("pass_rate"))
1142        .and_then(|v| v.as_f64());
1143
1144    Some(CardRow {
1145        full: json,
1146        summary: Summary {
1147            card_id,
1148            pkg: pkg.to_string(),
1149            created_at,
1150            model,
1151            scenario,
1152            pass_rate,
1153        },
1154    })
1155}
1156
1157/// Compare two Card rows according to an ordered list of sort keys.
1158fn order_cards(a: &CardRow, b: &CardRow, keys: &[OrderKey]) -> std::cmp::Ordering {
1159    use std::cmp::Ordering;
1160    for k in keys {
1161        let va = fetch_path(&a.full, &k.path);
1162        let vb = fetch_path(&b.full, &k.path);
1163        let ord = match (va, vb) {
1164            (None, None) => Ordering::Equal,
1165            (None, Some(_)) => Ordering::Greater, // missing sorts last
1166            (Some(_), None) => Ordering::Less,
1167            (Some(x), Some(y)) => json_cmp(x, y).unwrap_or(Ordering::Equal),
1168        };
1169        let ord = if k.desc { ord.reverse() } else { ord };
1170        if ord != Ordering::Equal {
1171            return ord;
1172        }
1173    }
1174    Ordering::Equal
1175}
1176
1177/// Summary-only fields that can be sorted without loading full TOML.
1178const SUMMARY_SORT_FIELDS: &[&str] = &[
1179    "card_id",
1180    "created_at",
1181    "stats.pass_rate",
1182    "scenario.name",
1183    "model.id",
1184];
1185
1186/// Return true when the query can be answered with lightweight Summary
1187/// rows (no full-TOML load needed).
1188fn is_lightweight_query(q: &FindQuery) -> bool {
1189    q.where_.is_none()
1190        && q.order_by
1191            .iter()
1192            .all(|k| SUMMARY_SORT_FIELDS.contains(&k.path.join(".").as_str()))
1193}
1194
1195/// Sort Summary rows using order_by keys that map to Summary fields.
1196fn order_summaries(a: &Summary, b: &Summary, keys: &[OrderKey]) -> std::cmp::Ordering {
1197    use std::cmp::Ordering;
1198    for k in keys {
1199        let key_str = k.path.join(".");
1200        let ord = match key_str.as_str() {
1201            "card_id" => a.card_id.cmp(&b.card_id),
1202            "created_at" => a.created_at.cmp(&b.created_at),
1203            "stats.pass_rate" => match (a.pass_rate, b.pass_rate) {
1204                (None, None) => Ordering::Equal,
1205                (None, Some(_)) => Ordering::Greater,
1206                (Some(_), None) => Ordering::Less,
1207                (Some(x), Some(y)) => x.partial_cmp(&y).unwrap_or(Ordering::Equal),
1208            },
1209            "scenario.name" => a.scenario.cmp(&b.scenario),
1210            "model.id" => a.model.cmp(&b.model),
1211            _ => Ordering::Equal,
1212        };
1213        let ord = if k.desc { ord.reverse() } else { ord };
1214        if ord != Ordering::Equal {
1215            return ord;
1216        }
1217    }
1218    Ordering::Equal
1219}
1220
1221/// Filter/sort Cards across the store using the `where` DSL.
1222///
1223/// When no `where` clause is specified and `order_by` only references
1224/// summary-level fields, uses the lightweight `list()` path to avoid
1225/// loading full TOML.  Otherwise loads full TOML per Card.
1226pub fn find(q: FindQuery) -> Result<Vec<Summary>, String> {
1227    // Fast path: lightweight query, no full-TOML load needed.
1228    if is_lightweight_query(&q) {
1229        let mut rows = list(q.pkg.as_deref())?;
1230        if q.order_by.is_empty() {
1231            rows.sort_by(|a, b| {
1232                b.created_at
1233                    .cmp(&a.created_at)
1234                    .then_with(|| b.card_id.cmp(&a.card_id))
1235            });
1236        } else {
1237            rows.sort_by(|a, b| order_summaries(a, b, &q.order_by));
1238        }
1239        let out: Vec<Summary> = rows
1240            .into_iter()
1241            .skip(q.offset.unwrap_or(0))
1242            .take(q.limit.unwrap_or(usize::MAX))
1243            .collect();
1244        return Ok(out);
1245    }
1246
1247    // Full path: load entire TOML for where evaluation / arbitrary order_by.
1248    let root = cards_dir()?;
1249    let pkg_dirs: Vec<PathBuf> = if let Some(p) = q.pkg.as_deref() {
1250        validate_name(p, "pkg")?;
1251        let d = root.join(p);
1252        if d.is_dir() {
1253            vec![d]
1254        } else {
1255            return Ok(Vec::new());
1256        }
1257    } else {
1258        fs::read_dir(&root)
1259            .map_err(|e| format!("Failed to read cards dir: {e}"))?
1260            .flatten()
1261            .map(|e| e.path())
1262            .filter(|p| p.is_dir())
1263            .collect()
1264    };
1265
1266    let all_rows = scan_pkg_dirs(&pkg_dirs)?;
1267
1268    // Filter by where.
1269    let mut rows: Vec<CardRow> = if let Some(pred) = &q.where_ {
1270        all_rows
1271            .into_iter()
1272            .filter(|row| eval_predicate(pred, &row.full))
1273            .collect()
1274    } else {
1275        all_rows
1276    };
1277
1278    // Sort.
1279    if q.order_by.is_empty() {
1280        rows.sort_by(|a, b| {
1281            b.summary
1282                .created_at
1283                .cmp(&a.summary.created_at)
1284                .then_with(|| b.summary.card_id.cmp(&a.summary.card_id))
1285        });
1286    } else {
1287        rows.sort_by(|a, b| order_cards(a, b, &q.order_by));
1288    }
1289
1290    // Offset + limit.
1291    let out: Vec<Summary> = rows
1292        .into_iter()
1293        .skip(q.offset.unwrap_or(0))
1294        .take(q.limit.unwrap_or(usize::MAX))
1295        .map(|r| r.summary)
1296        .collect();
1297
1298    Ok(out)
1299}
1300
1301// ───────────────────────────────────────────────────────────────
1302// Lineage walker
1303// ───────────────────────────────────────────────────────────────
1304//
1305// Cards form a tree (typically, not strictly a DAG) via the
1306// `metadata.prior_card_id` convention. `lineage()` walks that tree
1307// either up (toward ancestors) or down (toward descendants) or both,
1308// up to a configurable depth, optionally filtered by `prior_relation`.
1309//
1310// Up-walk is O(depth) — each step reads one parent Card.
1311// Down-walk is O(N_cards × depth) — we scan the whole store at each
1312// level. For the current scale (hundreds to low thousands of cards)
1313// this is fine; if the store grows we can build a prior_card_id index.
1314
1315/// Walk direction for `lineage`.
1316#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
1317pub enum LineageDirection {
1318    #[default]
1319    Up,
1320    Down,
1321    Both,
1322}
1323
1324impl LineageDirection {
1325    pub fn parse(s: &str) -> Result<Self, String> {
1326        match s {
1327            "up" => Ok(Self::Up),
1328            "down" => Ok(Self::Down),
1329            "both" => Ok(Self::Both),
1330            other => Err(format!(
1331                "direction must be 'up', 'down', or 'both' (got '{other}')"
1332            )),
1333        }
1334    }
1335}
1336
1337/// Query parameters for `lineage`.
1338#[derive(Debug, Clone, Default)]
1339pub struct LineageQuery {
1340    pub card_id: String,
1341    pub direction: LineageDirection,
1342    /// Max traversal depth. Default 10.
1343    pub depth: Option<usize>,
1344    /// Include a per-node `stats` field (full [stats] section).
1345    pub include_stats: bool,
1346    /// If set, only edges whose `prior_relation` is in this list are
1347    /// followed.  The root is always included regardless.
1348    pub relation_filter: Option<Vec<String>>,
1349}
1350
1351/// One node in the lineage result.
1352///
1353/// `depth` is the signed distance from the root: negative for
1354/// ancestors (up-walk), 0 for the root, positive for descendants.
1355#[derive(Debug, Clone)]
1356pub struct LineageNode {
1357    pub card_id: String,
1358    pub pkg: String,
1359    pub prior_card_id: Option<String>,
1360    pub prior_relation: Option<String>,
1361    pub depth: i32,
1362    pub stats: Option<Json>,
1363}
1364
1365/// One edge in the lineage result (child → parent, always).
1366#[derive(Debug, Clone)]
1367pub struct LineageEdge {
1368    pub from: String,
1369    pub to: String,
1370    pub relation: Option<String>,
1371}
1372
1373/// Full lineage walk result.
1374#[derive(Debug, Clone)]
1375pub struct LineageResult {
1376    pub root: String,
1377    pub nodes: Vec<LineageNode>,
1378    pub edges: Vec<LineageEdge>,
1379    pub truncated: bool,
1380}
1381
1382const DEFAULT_LINEAGE_DEPTH: usize = 10;
1383
1384/// Extract the lineage fields from a Card JSON.
1385/// Returns (prior_card_id, prior_relation).
1386fn lineage_fields(card: &Json) -> (Option<String>, Option<String>) {
1387    let meta = card.get("metadata");
1388    let prior_card_id = meta
1389        .and_then(|m| m.get("prior_card_id"))
1390        .and_then(|v| v.as_str())
1391        .map(String::from);
1392    let prior_relation = meta
1393        .and_then(|m| m.get("prior_relation"))
1394        .and_then(|v| v.as_str())
1395        .map(String::from);
1396    (prior_card_id, prior_relation)
1397}
1398
1399/// Build a LineageNode from a loaded CardRow at a given depth.
1400fn make_node(row: &CardRow, depth: i32, include_stats: bool) -> LineageNode {
1401    let (prior_card_id, prior_relation) = lineage_fields(&row.full);
1402    let stats = if include_stats {
1403        row.full.get("stats").cloned()
1404    } else {
1405        None
1406    };
1407    LineageNode {
1408        card_id: row.summary.card_id.clone(),
1409        pkg: row.summary.pkg.clone(),
1410        prior_card_id,
1411        prior_relation,
1412        depth,
1413        stats,
1414    }
1415}
1416
1417/// Check whether `relation` passes the relation_filter (None means no
1418/// filter, which always passes).
1419fn relation_passes(filter: &Option<Vec<String>>, relation: &Option<String>) -> bool {
1420    match filter {
1421        None => true,
1422        Some(allowed) => match relation {
1423            Some(r) => allowed.iter().any(|a| a == r),
1424            None => false,
1425        },
1426    }
1427}
1428
1429/// Full in-memory card index with forward and reverse lineage maps.
1430struct CardIndex {
1431    /// card_id → CardRow
1432    cards: std::collections::HashMap<String, CardRow>,
1433    /// parent card_id → Vec<child card_id> (reverse lineage index)
1434    children: std::collections::HashMap<String, Vec<String>>,
1435}
1436
1437/// Load all Cards in the store once, keyed by card_id.
1438/// Also builds a reverse index (parent → children) so that
1439/// `walk_down` is O(result_size) instead of O(N_cards × depth).
1440fn load_card_index() -> Result<CardIndex, String> {
1441    let root = cards_dir()?;
1442    let rows = scan_all_cards(&root)?;
1443
1444    let mut cards = std::collections::HashMap::with_capacity(rows.len());
1445    let mut children: std::collections::HashMap<String, Vec<String>> =
1446        std::collections::HashMap::new();
1447
1448    for row in rows {
1449        let id = row.summary.card_id.clone();
1450        let (prior_id, _) = lineage_fields(&row.full);
1451        if let Some(parent) = prior_id {
1452            children.entry(parent).or_default().push(id.clone());
1453        }
1454        cards.insert(id, row);
1455    }
1456    Ok(CardIndex { cards, children })
1457}
1458
1459/// Scan all Card TOML files from `root`, returning loaded CardRows.
1460/// Shared between `find` and `load_card_index`.
1461fn scan_all_cards(root: &std::path::Path) -> Result<Vec<CardRow>, String> {
1462    let pkg_dirs: Vec<PathBuf> = fs::read_dir(root)
1463        .map_err(|e| format!("Failed to read cards dir: {e}"))?
1464        .flatten()
1465        .map(|e| e.path())
1466        .filter(|p| p.is_dir())
1467        .collect();
1468    scan_pkg_dirs(&pkg_dirs)
1469}
1470
1471/// Scan a list of pkg directories, loading every Card in them.
1472fn scan_pkg_dirs(pkg_dirs: &[PathBuf]) -> Result<Vec<CardRow>, String> {
1473    let mut rows = Vec::new();
1474    for pdir in pkg_dirs {
1475        let pkg = pdir
1476            .file_name()
1477            .and_then(|s| s.to_str())
1478            .unwrap_or("")
1479            .to_string();
1480        let entries = match fs::read_dir(pdir) {
1481            Ok(e) => e,
1482            Err(_) => continue,
1483        };
1484        for entry in entries.flatten() {
1485            let p = entry.path();
1486            if p.extension().and_then(|s| s.to_str()) != Some("toml") {
1487                continue;
1488            }
1489            if let Some(row) = load_full(&p, &pkg) {
1490                rows.push(row);
1491            }
1492        }
1493    }
1494    Ok(rows)
1495}
1496
1497/// Invariant context passed through the lineage walkers.
1498struct LineageCtx<'a> {
1499    index: &'a CardIndex,
1500    relation_filter: &'a Option<Vec<String>>,
1501    include_stats: bool,
1502    max_depth: usize,
1503}
1504
1505/// Mutable accumulator for one lineage walk.
1506struct LineageAccum {
1507    nodes: Vec<LineageNode>,
1508    edges: Vec<LineageEdge>,
1509    visited: std::collections::HashSet<String>,
1510    truncated: bool,
1511}
1512
1513/// Walk ancestors via `metadata.prior_card_id`.
1514fn walk_up(start_id: &str, ctx: &LineageCtx<'_>, acc: &mut LineageAccum) {
1515    let mut cur = start_id.to_string();
1516    for step in 1..=ctx.max_depth {
1517        let Some(row) = ctx.index.cards.get(&cur) else {
1518            return;
1519        };
1520        let (prior_id, prior_rel) = lineage_fields(&row.full);
1521        let Some(prior_id) = prior_id else {
1522            return;
1523        };
1524        if !relation_passes(ctx.relation_filter, &prior_rel) {
1525            return;
1526        }
1527        if acc.visited.contains(&prior_id) {
1528            return;
1529        }
1530        let Some(parent) = ctx.index.cards.get(&prior_id) else {
1531            return;
1532        };
1533        acc.nodes
1534            .push(make_node(parent, -(step as i32), ctx.include_stats));
1535        acc.edges.push(LineageEdge {
1536            from: row.summary.card_id.clone(),
1537            to: parent.summary.card_id.clone(),
1538            relation: prior_rel,
1539        });
1540        acc.visited.insert(prior_id.clone());
1541        cur = prior_id;
1542    }
1543    // Depth exhausted but another unwalked parent exists → truncated.
1544    if let Some(row) = ctx.index.cards.get(&cur) {
1545        let (prior_id, _) = lineage_fields(&row.full);
1546        if prior_id
1547            .as_ref()
1548            .is_some_and(|p| ctx.index.cards.contains_key(p) && !acc.visited.contains(p))
1549        {
1550            acc.truncated = true;
1551        }
1552    }
1553}
1554
1555/// Walk descendants using the reverse index (parent → children),
1556/// breadth-first.  O(result_size) instead of O(N_cards × depth).
1557fn walk_down(start_id: &str, ctx: &LineageCtx<'_>, acc: &mut LineageAccum) {
1558    let mut frontier: Vec<String> = vec![start_id.to_string()];
1559
1560    for depth in 1..=ctx.max_depth {
1561        let mut next_frontier: Vec<String> = Vec::new();
1562        for parent_id in &frontier {
1563            let children = match ctx.index.children.get(parent_id) {
1564                Some(c) => c,
1565                None => continue,
1566            };
1567            for child_id in children {
1568                if acc.visited.contains(child_id) {
1569                    continue;
1570                }
1571                let Some(child) = ctx.index.cards.get(child_id) else {
1572                    continue;
1573                };
1574                let (_, prior_rel) = lineage_fields(&child.full);
1575                if !relation_passes(ctx.relation_filter, &prior_rel) {
1576                    continue;
1577                }
1578                acc.nodes
1579                    .push(make_node(child, depth as i32, ctx.include_stats));
1580                acc.edges.push(LineageEdge {
1581                    from: child.summary.card_id.clone(),
1582                    to: parent_id.clone(),
1583                    relation: prior_rel,
1584                });
1585                acc.visited.insert(child_id.clone());
1586                next_frontier.push(child_id.clone());
1587            }
1588        }
1589        if next_frontier.is_empty() {
1590            return;
1591        }
1592        frontier = next_frontier;
1593    }
1594    // Frontier still has nodes but depth is exhausted: check for
1595    // unwalked children at the next level.
1596    for parent_id in &frontier {
1597        let children = match ctx.index.children.get(parent_id) {
1598            Some(c) => c,
1599            None => continue,
1600        };
1601        for child_id in children {
1602            if acc.visited.contains(child_id) {
1603                continue;
1604            }
1605            let Some(child) = ctx.index.cards.get(child_id) else {
1606                continue;
1607            };
1608            let (_, prior_rel) = lineage_fields(&child.full);
1609            if relation_passes(ctx.relation_filter, &prior_rel) {
1610                acc.truncated = true;
1611                return;
1612            }
1613        }
1614    }
1615}
1616
1617/// Walk the lineage tree from `q.card_id`.
1618pub fn lineage(q: LineageQuery) -> Result<Option<LineageResult>, String> {
1619    let index = load_card_index()?;
1620    let Some(root_row) = index.cards.get(&q.card_id) else {
1621        return Ok(None);
1622    };
1623
1624    let ctx = LineageCtx {
1625        index: &index,
1626        relation_filter: &q.relation_filter,
1627        include_stats: q.include_stats,
1628        max_depth: q.depth.unwrap_or(DEFAULT_LINEAGE_DEPTH),
1629    };
1630    let mut acc = LineageAccum {
1631        nodes: Vec::new(),
1632        edges: Vec::new(),
1633        visited: std::collections::HashSet::new(),
1634        truncated: false,
1635    };
1636
1637    acc.nodes.push(make_node(root_row, 0, q.include_stats));
1638    acc.visited.insert(q.card_id.clone());
1639
1640    if matches!(q.direction, LineageDirection::Up | LineageDirection::Both) {
1641        walk_up(&q.card_id, &ctx, &mut acc);
1642    }
1643    if matches!(q.direction, LineageDirection::Down | LineageDirection::Both) {
1644        walk_down(&q.card_id, &ctx, &mut acc);
1645    }
1646
1647    Ok(Some(LineageResult {
1648        root: q.card_id,
1649        nodes: acc.nodes,
1650        edges: acc.edges,
1651        truncated: acc.truncated,
1652    }))
1653}
1654
1655/// Render a LineageResult as JSON for the service layer.
1656pub fn lineage_to_json(r: &LineageResult) -> Json {
1657    let nodes: Vec<Json> = r
1658        .nodes
1659        .iter()
1660        .map(|n| {
1661            let mut m = serde_json::Map::new();
1662            m.insert("card_id".into(), json!(n.card_id));
1663            m.insert("pkg".into(), json!(n.pkg));
1664            m.insert("depth".into(), json!(n.depth));
1665            if let Some(p) = &n.prior_card_id {
1666                m.insert("prior_card_id".into(), json!(p));
1667            }
1668            if let Some(rel) = &n.prior_relation {
1669                m.insert("prior_relation".into(), json!(rel));
1670            }
1671            if let Some(s) = &n.stats {
1672                m.insert("stats".into(), s.clone());
1673            }
1674            Json::Object(m)
1675        })
1676        .collect();
1677    let edges: Vec<Json> = r
1678        .edges
1679        .iter()
1680        .map(|e| {
1681            let mut m = serde_json::Map::new();
1682            m.insert("from".into(), json!(e.from));
1683            m.insert("to".into(), json!(e.to));
1684            if let Some(rel) = &e.relation {
1685                m.insert("relation".into(), json!(rel));
1686            }
1687            Json::Object(m)
1688        })
1689        .collect();
1690    json!({
1691        "root": r.root,
1692        "nodes": nodes,
1693        "edges": edges,
1694        "truncated": r.truncated,
1695    })
1696}
1697
1698// ───────────────────────────────────────────────────────────────
1699// Samples sidecar: per-case detail written alongside a Card as
1700// `{pkg}/{card_id}.samples.jsonl`. Write-once to preserve Card
1701// immutability: once a Card has a samples file, it cannot be
1702// rewritten — mismatched per-case data would break auditability.
1703// ───────────────────────────────────────────────────────────────
1704
1705// ───────────────────────────────────────────────────────────────
1706// Card import: copy Card files from an external directory into the
1707// local cards store. Used by `alc_card_install` (Card Collections)
1708// and by `alc_pkg_install` (Pkg-bundled cards/).
1709// ───────────────────────────────────────────────────────────────
1710
1711/// Import Card files from `source_dir` into `~/.algocline/cards/{pkg}/`.
1712///
1713/// Copies `*.toml` and `*.samples.jsonl` files. Existing cards with the
1714/// same id are skipped (first-writer wins — Card immutability).
1715///
1716/// Returns `(imported, skipped)` card_id lists.
1717pub fn import_from_dir(
1718    source_dir: &std::path::Path,
1719    pkg: &str,
1720) -> Result<(Vec<String>, Vec<String>), String> {
1721    validate_name(pkg, "pkg")?;
1722    let dest = pkg_dir(pkg)?;
1723    let mut imported = Vec::new();
1724    let mut skipped = Vec::new();
1725
1726    let entries =
1727        fs::read_dir(source_dir).map_err(|e| format!("Failed to read card source dir: {e}"))?;
1728
1729    for entry in entries.flatten() {
1730        let path = entry.path();
1731        let fname = match path.file_name().and_then(|n| n.to_str()) {
1732            Some(n) => n.to_string(),
1733            None => continue,
1734        };
1735
1736        // Only process .toml card files (not .samples.jsonl — those are handled below)
1737        if !fname.ends_with(".toml") {
1738            continue;
1739        }
1740
1741        let card_id = fname.trim_end_matches(".toml");
1742        let dest_toml = dest.join(&fname);
1743
1744        if dest_toml.exists() {
1745            skipped.push(card_id.to_string());
1746            continue;
1747        }
1748
1749        // Validate: must contain schema_version = "card/v0"
1750        let text = fs::read_to_string(&path)
1751            .map_err(|e| format!("Failed to read card file '{fname}': {e}"))?;
1752        let val: toml::Value = toml::from_str(&text)
1753            .map_err(|e| format!("Failed to parse card file '{fname}': {e}"))?;
1754        if val.get("schema_version").and_then(|v| v.as_str()) != Some(SCHEMA_VERSION) {
1755            continue; // skip non-card TOML files (e.g. index.toml, _aliases.toml)
1756        }
1757
1758        // Copy .toml
1759        fs::copy(&path, &dest_toml).map_err(|e| format!("Failed to copy card '{fname}': {e}"))?;
1760
1761        // Copy matching .samples.jsonl if present
1762        let samples_name = format!("{card_id}.samples.jsonl");
1763        let samples_src = source_dir.join(&samples_name);
1764        if samples_src.exists() {
1765            let samples_dest = dest.join(&samples_name);
1766            if !samples_dest.exists() {
1767                fs::copy(&samples_src, &samples_dest)
1768                    .map_err(|e| format!("Failed to copy samples '{samples_name}': {e}"))?;
1769            }
1770        }
1771
1772        imported.push(card_id.to_string());
1773    }
1774
1775    Ok((imported, skipped))
1776}
1777
1778/// Resolve the samples sidecar path for a Card.
1779///
1780/// Returns an error if the Card does not exist — samples without a
1781/// parent Card are meaningless and we refuse to create orphans.
1782fn samples_path(card_id: &str) -> Result<PathBuf, String> {
1783    let card_path =
1784        find_card_path(card_id)?.ok_or_else(|| format!("card '{card_id}' not found"))?;
1785    let dir = card_path
1786        .parent()
1787        .ok_or_else(|| format!("card '{card_id}' has no parent directory"))?;
1788    Ok(dir.join(format!("{card_id}.samples.jsonl")))
1789}
1790
1791/// Write per-case samples to `{card_id}.samples.jsonl` (write-once).
1792///
1793/// Each `samples` entry is serialized as one compact JSON line.
1794/// Fails if a samples file already exists for this card — mirrors
1795/// the immutability guarantee of Cards themselves.
1796pub fn write_samples(card_id: &str, samples: Vec<Json>) -> Result<PathBuf, String> {
1797    let path = samples_path(card_id)?;
1798    if path.exists() {
1799        return Err(format!(
1800            "alc.card.write_samples: samples already exist for card '{card_id}' (write-once)"
1801        ));
1802    }
1803    let mut buf = String::new();
1804    for (idx, s) in samples.iter().enumerate() {
1805        let line = serde_json::to_string(s).map_err(|e| {
1806            format!("alc.card.write_samples: failed to serialize sample #{idx}: {e}")
1807        })?;
1808        buf.push_str(&line);
1809        buf.push('\n');
1810    }
1811    let tmp = path.with_extension("jsonl.tmp");
1812    fs::write(&tmp, &buf).map_err(|e| format!("Failed to write samples tmp: {e}"))?;
1813    fs::rename(&tmp, &path).map_err(|e| format!("Failed to rename samples file: {e}"))?;
1814    Ok(path)
1815}
1816
1817/// Query parameters for `read_samples`.
1818#[derive(Debug, Default, Clone)]
1819pub struct SamplesQuery {
1820    /// Skip this many matched rows (after `where` filtering).
1821    pub offset: usize,
1822    /// Max matched rows to return.
1823    pub limit: Option<usize>,
1824    /// Optional `where` predicate applied to each sample row.
1825    /// The row JSON is the full line object (no section wrapping).
1826    pub where_: Option<Predicate>,
1827}
1828
1829/// Read per-case samples from `{card_id}.samples.jsonl`.
1830///
1831/// Streams the JSONL file line by line; rows are parsed, optionally
1832/// filtered by `q.where_`, then paged by `offset` + `limit`.  Offset
1833/// applies to the **post-filter** stream, matching Prisma/SQL
1834/// semantics.
1835///
1836/// Returns an empty Vec if no samples file exists (Cards without
1837/// per-case details are the common case, not an error).
1838pub fn read_samples(card_id: &str, q: SamplesQuery) -> Result<Vec<Json>, String> {
1839    let path = samples_path(card_id)?;
1840    if !path.exists() {
1841        return Ok(Vec::new());
1842    }
1843    let text =
1844        fs::read_to_string(&path).map_err(|e| format!("Failed to read samples file: {e}"))?;
1845    let mut matched: usize = 0;
1846    let mut out = Vec::new();
1847    for (i, line) in text.lines().enumerate() {
1848        if line.trim().is_empty() {
1849            continue;
1850        }
1851        let val: Json = serde_json::from_str(line)
1852            .map_err(|e| format!("Failed to parse sample line {i}: {e}"))?;
1853        if let Some(pred) = &q.where_ {
1854            if !eval_predicate(pred, &val) {
1855                continue;
1856            }
1857        }
1858        if matched < q.offset {
1859            matched += 1;
1860            continue;
1861        }
1862        if let Some(lim) = q.limit {
1863            if out.len() >= lim {
1864                break;
1865            }
1866        }
1867        matched += 1;
1868        out.push(val);
1869    }
1870    Ok(out)
1871}
1872
1873#[cfg(test)]
1874mod tests {
1875    use super::*;
1876
1877    fn unique_pkg() -> String {
1878        let ns = std::time::SystemTime::now()
1879            .duration_since(std::time::UNIX_EPOCH)
1880            .unwrap()
1881            .as_nanos();
1882        format!("_test_card_{ns}")
1883    }
1884
1885    fn cleanup(pkg: &str) {
1886        if let Ok(d) = pkg_dir(pkg) {
1887            let _ = fs::remove_dir_all(&d);
1888        }
1889    }
1890
1891    #[test]
1892    fn minimum_valid_card() {
1893        let pkg = unique_pkg();
1894        let input = json!({ "pkg": { "name": pkg } });
1895        let (id, path) = create(input).unwrap();
1896        assert!(path.exists());
1897        assert!(id.starts_with(&pkg));
1898
1899        let got = get(&id).unwrap().unwrap();
1900        assert_eq!(got["schema_version"], json!(SCHEMA_VERSION));
1901        assert_eq!(got["card_id"], json!(id));
1902        assert_eq!(got["pkg"]["name"], json!(pkg));
1903        assert!(got.get("created_at").is_some());
1904        assert!(got.get("created_by").is_some());
1905
1906        cleanup(&pkg);
1907    }
1908
1909    #[test]
1910    fn create_rejects_missing_pkg_name() {
1911        let err = create(json!({})).unwrap_err();
1912        assert!(err.contains("pkg.name"));
1913    }
1914
1915    #[test]
1916    fn create_is_immutable() {
1917        let pkg = unique_pkg();
1918        let input = json!({
1919            "card_id": "fixed_id_001",
1920            "pkg": { "name": pkg }
1921        });
1922        create(input.clone()).unwrap();
1923        let err = create(input).unwrap_err();
1924        assert!(err.contains("already exists"));
1925        cleanup(&pkg);
1926    }
1927
1928    #[test]
1929    fn create_injects_param_fingerprint() {
1930        let pkg = unique_pkg();
1931        let input = json!({
1932            "pkg": { "name": pkg },
1933            "params": { "depth": 3, "temperature": 0.0 }
1934        });
1935        let (id, _) = create(input).unwrap();
1936        let got = get(&id).unwrap().unwrap();
1937        assert!(got["param_fingerprint"].is_string());
1938        cleanup(&pkg);
1939    }
1940
1941    #[test]
1942    fn list_returns_newest_first() {
1943        let pkg = unique_pkg();
1944        // First card
1945        let (id1, _) = create(json!({
1946            "card_id": format!("{pkg}_a"),
1947            "pkg": { "name": pkg },
1948            "created_at": "2025-01-01T00:00:00Z"
1949        }))
1950        .unwrap();
1951        let (id2, _) = create(json!({
1952            "card_id": format!("{pkg}_b"),
1953            "pkg": { "name": pkg },
1954            "created_at": "2026-01-01T00:00:00Z"
1955        }))
1956        .unwrap();
1957
1958        let rows = list(Some(&pkg)).unwrap();
1959        assert_eq!(rows.len(), 2);
1960        assert_eq!(rows[0].card_id, id2); // newer first
1961        assert_eq!(rows[1].card_id, id1);
1962
1963        cleanup(&pkg);
1964    }
1965
1966    #[test]
1967    fn list_extracts_summary_fields() {
1968        let pkg = unique_pkg();
1969        let (id, _) = create(json!({
1970            "pkg": { "name": pkg },
1971            "model": { "id": "claude-opus-4-6" },
1972            "scenario": { "name": "gsm8k_sample100" },
1973            "stats": { "pass_rate": 0.82 }
1974        }))
1975        .unwrap();
1976
1977        let rows = list(Some(&pkg)).unwrap();
1978        let row = rows.iter().find(|r| r.card_id == id).unwrap();
1979        assert_eq!(row.model.as_deref(), Some("claude-opus-4-6"));
1980        assert_eq!(row.scenario.as_deref(), Some("gsm8k_sample100"));
1981        assert_eq!(row.pass_rate, Some(0.82));
1982
1983        cleanup(&pkg);
1984    }
1985
1986    #[test]
1987    fn get_missing_returns_none() {
1988        assert!(get("does_not_exist_xyz").unwrap().is_none());
1989    }
1990
1991    #[test]
1992    fn card_id_embeds_compact_timestamp() {
1993        let pkg = unique_pkg();
1994        let (id, _) = create(json!({ "pkg": { "name": pkg } })).unwrap();
1995        // Expect: {pkg}_{model}_{YYYYMMDDTHHMMSS}_{hash6}
1996        // After removing the pkg prefix, there should be a segment
1997        // containing 'T' separating date and time.
1998        let tail = id.strip_prefix(&format!("{pkg}_")).unwrap();
1999        let parts: Vec<&str> = tail.split('_').collect();
2000        // parts = [model_short, YYYYMMDDTHHMMSS, hash6]
2001        assert_eq!(parts.len(), 3, "unexpected card_id shape: {id}");
2002        let ts = parts[1];
2003        assert_eq!(ts.len(), 15, "timestamp segment wrong length: {ts}");
2004        assert!(ts.chars().nth(8) == Some('T'), "missing T separator: {ts}");
2005        cleanup(&pkg);
2006    }
2007
2008    #[test]
2009    fn now_compact_format() {
2010        let s = now_compact();
2011        assert_eq!(s.len(), 15);
2012        assert_eq!(s.chars().nth(8), Some('T'));
2013        // All other positions are digits
2014        for (i, c) in s.chars().enumerate() {
2015            if i != 8 {
2016                assert!(c.is_ascii_digit(), "non-digit at pos {i}: {s}");
2017            }
2018        }
2019    }
2020
2021    #[test]
2022    fn short_model_variants() {
2023        assert_eq!(short_model("claude-opus-4-6"), "opus46");
2024        assert_eq!(short_model("gpt-4o"), "4o");
2025        assert_eq!(short_model(""), "model");
2026    }
2027
2028    #[test]
2029    fn two_cards_same_second_different_stats_get_distinct_ids() {
2030        let pkg = unique_pkg();
2031        let input1 = json!({
2032            "pkg": { "name": pkg },
2033            "scenario": { "name": "gsm8k" },
2034            "stats": { "pass_rate": 0.4 }
2035        });
2036        let input2 = json!({
2037            "pkg": { "name": pkg },
2038            "scenario": { "name": "gsm8k" },
2039            "stats": { "pass_rate": 0.9 }
2040        });
2041        let (id1, _) = create(input1).unwrap();
2042        let (id2, _) = create(input2).unwrap();
2043        assert_ne!(id1, id2, "distinct stats must yield distinct card_ids");
2044        cleanup(&pkg);
2045    }
2046
2047    // ─── P1: append ────────────────────────────────────────────
2048
2049    #[test]
2050    fn append_adds_new_fields() {
2051        let pkg = unique_pkg();
2052        let (id, _) = create(json!({
2053            "pkg": { "name": pkg },
2054            "stats": { "pass_rate": 0.5 }
2055        }))
2056        .unwrap();
2057
2058        let merged = append(
2059            &id,
2060            json!({
2061                "caveats": { "notes": "rescored after fix" },
2062                "metadata": { "reviewer": "yn" }
2063            }),
2064        )
2065        .unwrap();
2066        assert_eq!(merged["caveats"]["notes"], json!("rescored after fix"));
2067        assert_eq!(merged["metadata"]["reviewer"], json!("yn"));
2068
2069        // Persisted
2070        let got = get(&id).unwrap().unwrap();
2071        assert_eq!(got["caveats"]["notes"], json!("rescored after fix"));
2072        // Existing field untouched
2073        assert_eq!(got["stats"]["pass_rate"], json!(0.5));
2074
2075        cleanup(&pkg);
2076    }
2077
2078    #[test]
2079    fn append_rejects_existing_key() {
2080        let pkg = unique_pkg();
2081        let (id, _) = create(json!({
2082            "pkg": { "name": pkg },
2083            "stats": { "pass_rate": 0.5 }
2084        }))
2085        .unwrap();
2086
2087        let err = append(&id, json!({ "stats": { "pass_rate": 0.9 } })).unwrap_err();
2088        assert!(err.contains("already set"), "got: {err}");
2089        // Verify original value still there
2090        let got = get(&id).unwrap().unwrap();
2091        assert_eq!(got["stats"]["pass_rate"], json!(0.5));
2092
2093        cleanup(&pkg);
2094    }
2095
2096    #[test]
2097    fn append_errors_on_missing_card() {
2098        let err = append("does_not_exist_xyz", json!({ "x": 1 })).unwrap_err();
2099        assert!(err.contains("not found"));
2100    }
2101
2102    // ─── P1: alias_set / alias_list ────────────────────────────
2103
2104    #[test]
2105    fn alias_set_and_list_roundtrip() {
2106        let pkg = unique_pkg();
2107        let (id, _) = create(json!({ "pkg": { "name": pkg } })).unwrap();
2108
2109        let alias_name = format!("test_alias_{}", &pkg);
2110        alias_set(&alias_name, &id, Some(&pkg), Some("smoke")).unwrap();
2111
2112        let rows = alias_list(Some(&pkg)).unwrap();
2113        let a = rows.iter().find(|a| a.name == alias_name).unwrap();
2114        assert_eq!(a.card_id, id);
2115        assert_eq!(a.pkg.as_deref(), Some(pkg.as_str()));
2116        assert_eq!(a.note.as_deref(), Some("smoke"));
2117        assert!(!a.set_at.is_empty());
2118
2119        // Rebind to a new card
2120        let (id2, _) = create(json!({
2121            "card_id": format!("{pkg}_b"),
2122            "pkg": { "name": pkg }
2123        }))
2124        .unwrap();
2125        alias_set(&alias_name, &id2, Some(&pkg), None).unwrap();
2126        let rows = alias_list(Some(&pkg)).unwrap();
2127        let matching: Vec<&Alias> = rows.iter().filter(|a| a.name == alias_name).collect();
2128        assert_eq!(matching.len(), 1, "alias should be unique by name");
2129        assert_eq!(matching[0].card_id, id2);
2130
2131        // Cleanup: remove our alias from the file
2132        let remaining: Vec<Alias> = read_aliases()
2133            .unwrap()
2134            .into_iter()
2135            .filter(|a| a.name != alias_name)
2136            .collect();
2137        write_aliases(&remaining).unwrap();
2138        cleanup(&pkg);
2139    }
2140
2141    #[test]
2142    fn alias_set_rejects_unknown_card() {
2143        let err = alias_set("x", "does_not_exist_xyz", None, None).unwrap_err();
2144        assert!(err.contains("not found"));
2145    }
2146
2147    // ─── find + where DSL ───────────────────────────────────────
2148
2149    fn where_from(v: Json) -> Predicate {
2150        parse_where(&v).expect("parse where")
2151    }
2152
2153    fn order_from(v: Json) -> Vec<OrderKey> {
2154        parse_order_by(&v).expect("parse order_by")
2155    }
2156
2157    #[test]
2158    fn find_where_nested_eq_and_gte() {
2159        let pkg = unique_pkg();
2160        create(json!({
2161            "card_id": format!("{pkg}_low"),
2162            "pkg": { "name": pkg },
2163            "scenario": { "name": "gsm8k" },
2164            "stats": { "pass_rate": 0.4 }
2165        }))
2166        .unwrap();
2167        create(json!({
2168            "card_id": format!("{pkg}_high"),
2169            "pkg": { "name": pkg },
2170            "scenario": { "name": "gsm8k" },
2171            "stats": { "pass_rate": 0.9 }
2172        }))
2173        .unwrap();
2174        create(json!({
2175            "card_id": format!("{pkg}_other"),
2176            "pkg": { "name": pkg },
2177            "scenario": { "name": "other" },
2178            "stats": { "pass_rate": 1.0 }
2179        }))
2180        .unwrap();
2181
2182        // scenario eq via nested object
2183        let rows = find(FindQuery {
2184            pkg: Some(pkg.clone()),
2185            where_: Some(where_from(json!({
2186                "scenario": { "name": "gsm8k" },
2187            }))),
2188            order_by: order_from(json!("-stats.pass_rate")),
2189            ..Default::default()
2190        })
2191        .unwrap();
2192        assert_eq!(rows.len(), 2);
2193        assert_eq!(rows[0].pass_rate, Some(0.9));
2194        assert_eq!(rows[1].pass_rate, Some(0.4));
2195
2196        // gte operator
2197        let rows = find(FindQuery {
2198            pkg: Some(pkg.clone()),
2199            where_: Some(where_from(json!({
2200                "stats": { "pass_rate": { "gte": 0.8 } },
2201            }))),
2202            order_by: order_from(json!("-stats.pass_rate")),
2203            ..Default::default()
2204        })
2205        .unwrap();
2206        assert_eq!(rows.len(), 2);
2207        assert!(rows.iter().all(|r| r.pass_rate.unwrap() >= 0.8));
2208
2209        // limit
2210        let rows = find(FindQuery {
2211            pkg: Some(pkg.clone()),
2212            order_by: order_from(json!("-stats.pass_rate")),
2213            limit: Some(1),
2214            ..Default::default()
2215        })
2216        .unwrap();
2217        assert_eq!(rows.len(), 1);
2218        assert_eq!(rows[0].pass_rate, Some(1.0));
2219
2220        cleanup(&pkg);
2221    }
2222
2223    #[test]
2224    fn find_where_implicit_eq_and_logical() {
2225        let pkg = unique_pkg();
2226        create(json!({
2227            "card_id": format!("{pkg}_a"),
2228            "pkg": { "name": pkg },
2229            "model": { "id": "claude-opus-4-6" },
2230            "stats": { "equilibrium_position": "dead", "survival_rate": 0.0 }
2231        }))
2232        .unwrap();
2233        create(json!({
2234            "card_id": format!("{pkg}_b"),
2235            "pkg": { "name": pkg },
2236            "model": { "id": "claude-opus-4-6" },
2237            "stats": { "equilibrium_position": "niche_leader", "survival_rate": 1.0 }
2238        }))
2239        .unwrap();
2240        create(json!({
2241            "card_id": format!("{pkg}_c"),
2242            "pkg": { "name": pkg },
2243            "model": { "id": "claude-haiku-4-5-20251001" },
2244            "stats": { "equilibrium_position": "fragile", "survival_rate": 0.2 }
2245        }))
2246        .unwrap();
2247
2248        // implicit eq on sparse stats field
2249        let rows = find(FindQuery {
2250            pkg: Some(pkg.clone()),
2251            where_: Some(where_from(json!({
2252                "stats": { "equilibrium_position": "dead" },
2253            }))),
2254            ..Default::default()
2255        })
2256        .unwrap();
2257        assert_eq!(rows.len(), 1);
2258        assert!(rows[0].card_id.ends_with("_a"));
2259
2260        // _or
2261        let rows = find(FindQuery {
2262            pkg: Some(pkg.clone()),
2263            where_: Some(where_from(json!({
2264                "_or": [
2265                    { "stats": { "equilibrium_position": "dead" } },
2266                    { "stats": { "survival_rate": { "gte": 0.9 } } },
2267                ],
2268            }))),
2269            ..Default::default()
2270        })
2271        .unwrap();
2272        assert_eq!(rows.len(), 2);
2273
2274        // _not
2275        let rows = find(FindQuery {
2276            pkg: Some(pkg.clone()),
2277            where_: Some(where_from(json!({
2278                "_not": { "model": { "id": "claude-haiku-4-5-20251001" } },
2279            }))),
2280            ..Default::default()
2281        })
2282        .unwrap();
2283        assert_eq!(rows.len(), 2);
2284
2285        // in operator
2286        let rows = find(FindQuery {
2287            pkg: Some(pkg.clone()),
2288            where_: Some(where_from(json!({
2289                "stats": {
2290                    "equilibrium_position": { "in": ["dead", "fragile"] },
2291                },
2292            }))),
2293            ..Default::default()
2294        })
2295        .unwrap();
2296        assert_eq!(rows.len(), 2);
2297
2298        // exists false (sparse field missing on haiku card? all have it, so test on
2299        // a field that only some have)
2300        let rows = find(FindQuery {
2301            pkg: Some(pkg.clone()),
2302            where_: Some(where_from(json!({
2303                "strategy_params": { "temperature": { "exists": false } },
2304            }))),
2305            ..Default::default()
2306        })
2307        .unwrap();
2308        assert_eq!(rows.len(), 3, "none of the cards have strategy_params");
2309
2310        cleanup(&pkg);
2311    }
2312
2313    #[test]
2314    fn find_order_by_multi_key() {
2315        let pkg = unique_pkg();
2316        create(json!({
2317            "card_id": format!("{pkg}_a"),
2318            "pkg": { "name": pkg },
2319            "stats": { "pass_rate": 0.5 }
2320        }))
2321        .unwrap();
2322        create(json!({
2323            "card_id": format!("{pkg}_b"),
2324            "pkg": { "name": pkg },
2325            "stats": { "pass_rate": 0.9 }
2326        }))
2327        .unwrap();
2328        create(json!({
2329            "card_id": format!("{pkg}_c"),
2330            "pkg": { "name": pkg },
2331            "stats": { "pass_rate": 0.9 }
2332        }))
2333        .unwrap();
2334
2335        let rows = find(FindQuery {
2336            pkg: Some(pkg.clone()),
2337            order_by: order_from(json!(["-stats.pass_rate", "card_id"])),
2338            ..Default::default()
2339        })
2340        .unwrap();
2341        assert_eq!(rows.len(), 3);
2342        assert_eq!(rows[0].pass_rate, Some(0.9));
2343        assert_eq!(rows[1].pass_rate, Some(0.9));
2344        assert_eq!(rows[2].pass_rate, Some(0.5));
2345        // Tiebreak by card_id ascending
2346        assert!(rows[0].card_id < rows[1].card_id);
2347
2348        cleanup(&pkg);
2349    }
2350
2351    #[test]
2352    fn find_offset_and_limit() {
2353        let pkg = unique_pkg();
2354        for i in 0..5 {
2355            create(json!({
2356                "card_id": format!("{pkg}_{i}"),
2357                "pkg": { "name": pkg },
2358                "stats": { "pass_rate": 0.1 * (i + 1) as f64 }
2359            }))
2360            .unwrap();
2361        }
2362
2363        let rows = find(FindQuery {
2364            pkg: Some(pkg.clone()),
2365            order_by: order_from(json!("-stats.pass_rate")),
2366            offset: Some(1),
2367            limit: Some(2),
2368            ..Default::default()
2369        })
2370        .unwrap();
2371        assert_eq!(rows.len(), 2);
2372        // Best is 0.5, after offset=1 we start at 0.4 then 0.3.
2373        let pr0 = rows[0].pass_rate.unwrap();
2374        let pr1 = rows[1].pass_rate.unwrap();
2375        assert!((pr0 - 0.4).abs() < 1e-9, "got {pr0}");
2376        assert!((pr1 - 0.3).abs() < 1e-9, "got {pr1}");
2377
2378        cleanup(&pkg);
2379    }
2380
2381    #[test]
2382    fn parse_where_rejects_non_object() {
2383        assert!(parse_where(&json!("not an object")).is_err());
2384        assert!(parse_where(&json!(42)).is_err());
2385    }
2386
2387    #[test]
2388    fn parse_order_by_accepts_string_and_array() {
2389        let k = parse_order_by(&json!("-stats.pass_rate")).unwrap();
2390        assert_eq!(k.len(), 1);
2391        assert_eq!(k[0].path, vec!["stats", "pass_rate"]);
2392        assert!(k[0].desc);
2393
2394        let k = parse_order_by(&json!(["created_at", "-stats.n"])).unwrap();
2395        assert_eq!(k.len(), 2);
2396        assert!(!k[0].desc);
2397        assert!(k[1].desc);
2398    }
2399
2400    #[test]
2401    fn find_where_string_ops_contains_and_starts_with() {
2402        let pkg = unique_pkg();
2403        create(json!({
2404            "card_id": format!("{pkg}_a"),
2405            "pkg": { "name": pkg },
2406            "model": { "id": "claude-opus-4-6" },
2407            "metadata": { "tag": "experiment_alpha" },
2408        }))
2409        .unwrap();
2410        create(json!({
2411            "card_id": format!("{pkg}_b"),
2412            "pkg": { "name": pkg },
2413            "model": { "id": "claude-haiku-4-5-20251001" },
2414            "metadata": { "tag": "experiment_beta" },
2415        }))
2416        .unwrap();
2417        create(json!({
2418            "card_id": format!("{pkg}_c"),
2419            "pkg": { "name": pkg },
2420            "model": { "id": "claude-sonnet-4-5" },
2421            "metadata": { "tag": "baseline" },
2422        }))
2423        .unwrap();
2424
2425        // contains: matches substring anywhere
2426        let rows = find(FindQuery {
2427            pkg: Some(pkg.clone()),
2428            where_: Some(where_from(json!({
2429                "metadata": { "tag": { "contains": "experiment" } },
2430            }))),
2431            ..Default::default()
2432        })
2433        .unwrap();
2434        assert_eq!(rows.len(), 2);
2435
2436        // starts_with: matches only the prefix
2437        let rows = find(FindQuery {
2438            pkg: Some(pkg.clone()),
2439            where_: Some(where_from(json!({
2440                "model": { "id": { "starts_with": "claude-opus" } },
2441            }))),
2442            ..Default::default()
2443        })
2444        .unwrap();
2445        assert_eq!(rows.len(), 1);
2446        assert!(rows[0].card_id.ends_with("_a"));
2447
2448        // string ops on missing field → false
2449        let rows = find(FindQuery {
2450            pkg: Some(pkg.clone()),
2451            where_: Some(where_from(json!({
2452                "metadata": { "missing_field": { "contains": "x" } },
2453            }))),
2454            ..Default::default()
2455        })
2456        .unwrap();
2457        assert_eq!(rows.len(), 0);
2458
2459        // string ops on non-string field → false
2460        let rows = find(FindQuery {
2461            pkg: Some(pkg.clone()),
2462            where_: Some(where_from(json!({
2463                "metadata": { "tag": { "starts_with": 42 } },
2464            }))),
2465            ..Default::default()
2466        })
2467        .unwrap();
2468        assert_eq!(rows.len(), 0);
2469
2470        cleanup(&pkg);
2471    }
2472
2473    #[test]
2474    fn where_missing_field_ne_is_true() {
2475        let pkg = unique_pkg();
2476        create(json!({
2477            "card_id": format!("{pkg}_x"),
2478            "pkg": { "name": pkg },
2479        }))
2480        .unwrap();
2481
2482        let rows = find(FindQuery {
2483            pkg: Some(pkg.clone()),
2484            where_: Some(where_from(json!({
2485                "strategy_params": { "temperature": { "ne": 0.5 } },
2486            }))),
2487            ..Default::default()
2488        })
2489        .unwrap();
2490        assert_eq!(rows.len(), 1, "missing field is ne to anything");
2491
2492        cleanup(&pkg);
2493    }
2494
2495    // ─── lineage ───────────────────────────────────────────────
2496
2497    /// Helper: create a child Card pointing at a parent with a relation.
2498    fn create_child(pkg: &str, suffix: &str, parent_id: &str, relation: &str) -> String {
2499        let (id, _) = create(json!({
2500            "card_id": format!("{pkg}_{suffix}"),
2501            "pkg": { "name": pkg },
2502            "stats": { "pass_rate": 0.5 },
2503            "metadata": {
2504                "prior_card_id": parent_id,
2505                "prior_relation": relation,
2506            },
2507        }))
2508        .unwrap();
2509        id
2510    }
2511
2512    #[test]
2513    fn lineage_up_walks_prior_card_id_chain() {
2514        let pkg = unique_pkg();
2515        // a → b → c (c is newest; b points at a; c points at b)
2516        let (a, _) = create(json!({
2517            "card_id": format!("{pkg}_a"),
2518            "pkg": { "name": pkg },
2519        }))
2520        .unwrap();
2521        let b = create_child(&pkg, "b", &a, "rerun_of");
2522        let c = create_child(&pkg, "c", &b, "rerun_of");
2523
2524        let res = lineage(LineageQuery {
2525            card_id: c.clone(),
2526            direction: LineageDirection::Up,
2527            depth: None,
2528            include_stats: false,
2529            relation_filter: None,
2530        })
2531        .unwrap()
2532        .expect("lineage result");
2533
2534        assert_eq!(res.root, c);
2535        assert_eq!(res.nodes.len(), 3, "root + 2 ancestors");
2536        assert_eq!(res.nodes[0].card_id, c);
2537        assert_eq!(res.nodes[0].depth, 0);
2538        assert_eq!(res.nodes[1].card_id, b);
2539        assert_eq!(res.nodes[1].depth, -1);
2540        assert_eq!(res.nodes[2].card_id, a);
2541        assert_eq!(res.nodes[2].depth, -2);
2542        assert_eq!(res.edges.len(), 2);
2543        assert!(!res.truncated);
2544
2545        cleanup(&pkg);
2546    }
2547
2548    #[test]
2549    fn lineage_down_walks_descendants_breadth_first() {
2550        let pkg = unique_pkg();
2551        // a has two children b, c; c has one child d.
2552        let (a, _) = create(json!({
2553            "card_id": format!("{pkg}_a"),
2554            "pkg": { "name": pkg },
2555        }))
2556        .unwrap();
2557        let _b = create_child(&pkg, "b", &a, "sweep_variant");
2558        let c = create_child(&pkg, "c", &a, "sweep_variant");
2559        let _d = create_child(&pkg, "d", &c, "rerun_of");
2560
2561        let res = lineage(LineageQuery {
2562            card_id: a.clone(),
2563            direction: LineageDirection::Down,
2564            depth: None,
2565            include_stats: false,
2566            relation_filter: None,
2567        })
2568        .unwrap()
2569        .expect("lineage result");
2570
2571        // root + b + c + d = 4 nodes
2572        assert_eq!(res.nodes.len(), 4);
2573        assert_eq!(res.edges.len(), 3);
2574        assert!(!res.truncated);
2575
2576        cleanup(&pkg);
2577    }
2578
2579    #[test]
2580    fn lineage_depth_truncation_sets_flag() {
2581        let pkg = unique_pkg();
2582        let (a, _) = create(json!({
2583            "card_id": format!("{pkg}_a"),
2584            "pkg": { "name": pkg },
2585        }))
2586        .unwrap();
2587        let b = create_child(&pkg, "b", &a, "rerun_of");
2588        let _c = create_child(&pkg, "c", &b, "rerun_of");
2589
2590        let res = lineage(LineageQuery {
2591            card_id: a,
2592            direction: LineageDirection::Down,
2593            depth: Some(1),
2594            include_stats: false,
2595            relation_filter: None,
2596        })
2597        .unwrap()
2598        .unwrap();
2599        assert_eq!(res.nodes.len(), 2, "root + 1 level");
2600        assert!(res.truncated, "should be truncated at depth=1");
2601
2602        cleanup(&pkg);
2603    }
2604
2605    #[test]
2606    fn lineage_relation_filter_skips_unlisted() {
2607        let pkg = unique_pkg();
2608        let (a, _) = create(json!({
2609            "card_id": format!("{pkg}_a"),
2610            "pkg": { "name": pkg },
2611        }))
2612        .unwrap();
2613        let _b = create_child(&pkg, "b", &a, "sweep_variant");
2614        let _c = create_child(&pkg, "c", &a, "rerun_of");
2615
2616        let res = lineage(LineageQuery {
2617            card_id: a,
2618            direction: LineageDirection::Down,
2619            depth: None,
2620            include_stats: false,
2621            relation_filter: Some(vec!["sweep_variant".to_string()]),
2622        })
2623        .unwrap()
2624        .unwrap();
2625        assert_eq!(res.nodes.len(), 2, "root + only sweep_variant child");
2626        assert_eq!(res.edges[0].relation.as_deref(), Some("sweep_variant"));
2627
2628        cleanup(&pkg);
2629    }
2630
2631    #[test]
2632    fn lineage_missing_card_returns_none() {
2633        let res = lineage(LineageQuery {
2634            card_id: "nonexistent_card_id_xyz".into(),
2635            direction: LineageDirection::Up,
2636            depth: None,
2637            include_stats: false,
2638            relation_filter: None,
2639        })
2640        .unwrap();
2641        assert!(res.is_none());
2642    }
2643
2644    // ─── samples sidecar ───────────────────────────────────────
2645
2646    #[test]
2647    fn write_and_read_samples_roundtrip() {
2648        let pkg = unique_pkg();
2649        let (id, _) = create(json!({
2650            "pkg": { "name": pkg },
2651            "stats": { "pass_rate": 0.5 }
2652        }))
2653        .unwrap();
2654
2655        let samples = vec![
2656            json!({ "case": "c0", "passed": true, "score": 1.0 }),
2657            json!({ "case": "c1", "passed": false, "score": 0.0 }),
2658            json!({ "case": "c2", "passed": true, "score": 0.75 }),
2659        ];
2660        let path = write_samples(&id, samples.clone()).unwrap();
2661        assert!(path.exists());
2662        assert!(path.to_string_lossy().ends_with(".samples.jsonl"));
2663
2664        let got = read_samples(&id, SamplesQuery::default()).unwrap();
2665        assert_eq!(got.len(), 3);
2666        assert_eq!(got[0]["case"], json!("c0"));
2667        assert_eq!(got[2]["score"], json!(0.75));
2668
2669        // offset + limit
2670        let slice = read_samples(
2671            &id,
2672            SamplesQuery {
2673                offset: 1,
2674                limit: Some(1),
2675                where_: None,
2676            },
2677        )
2678        .unwrap();
2679        assert_eq!(slice.len(), 1);
2680        assert_eq!(slice[0]["case"], json!("c1"));
2681
2682        cleanup(&pkg);
2683    }
2684
2685    #[test]
2686    fn write_samples_is_write_once() {
2687        let pkg = unique_pkg();
2688        let (id, _) = create(json!({ "pkg": { "name": pkg } })).unwrap();
2689        write_samples(&id, vec![json!({ "x": 1 })]).unwrap();
2690        let err = write_samples(&id, vec![json!({ "x": 2 })]).unwrap_err();
2691        assert!(err.contains("already exist"), "got: {err}");
2692        cleanup(&pkg);
2693    }
2694
2695    #[test]
2696    fn read_samples_empty_when_absent() {
2697        let pkg = unique_pkg();
2698        let (id, _) = create(json!({ "pkg": { "name": pkg } })).unwrap();
2699        let got = read_samples(&id, SamplesQuery::default()).unwrap();
2700        assert!(got.is_empty());
2701        cleanup(&pkg);
2702    }
2703
2704    #[test]
2705    fn read_samples_where_filters_rows() {
2706        let pkg = unique_pkg();
2707        let (id, _) = create(json!({ "pkg": { "name": pkg } })).unwrap();
2708        write_samples(
2709            &id,
2710            vec![
2711                json!({ "case": "c0", "passed": true,  "score": 1.0 }),
2712                json!({ "case": "c1", "passed": false, "score": 0.0 }),
2713                json!({ "case": "c2", "passed": true,  "score": 0.25 }),
2714                json!({ "case": "c3", "passed": true,  "score": 0.75 }),
2715                json!({ "case": "c4", "passed": false, "score": 0.5 }),
2716            ],
2717        )
2718        .unwrap();
2719
2720        // Equality predicate: passed == true keeps 3 rows.
2721        let pred = parse_where(&json!({ "passed": true })).unwrap();
2722        let got = read_samples(
2723            &id,
2724            SamplesQuery {
2725                offset: 0,
2726                limit: None,
2727                where_: Some(pred),
2728            },
2729        )
2730        .unwrap();
2731        assert_eq!(got.len(), 3);
2732        assert_eq!(got[0]["case"], json!("c0"));
2733        assert_eq!(got[1]["case"], json!("c2"));
2734        assert_eq!(got[2]["case"], json!("c3"));
2735
2736        // Nested comparator: score gte 0.5 keeps c0/c3/c4.
2737        let pred = parse_where(&json!({ "score": { "gte": 0.5 } })).unwrap();
2738        let got = read_samples(
2739            &id,
2740            SamplesQuery {
2741                offset: 0,
2742                limit: None,
2743                where_: Some(pred),
2744            },
2745        )
2746        .unwrap();
2747        assert_eq!(got.len(), 3);
2748        assert_eq!(got[0]["case"], json!("c0"));
2749        assert_eq!(got[1]["case"], json!("c3"));
2750        assert_eq!(got[2]["case"], json!("c4"));
2751
2752        // Offset applies AFTER filter: passed=true then skip 1 + limit 1 → c2.
2753        let pred = parse_where(&json!({ "passed": true })).unwrap();
2754        let slice = read_samples(
2755            &id,
2756            SamplesQuery {
2757                offset: 1,
2758                limit: Some(1),
2759                where_: Some(pred),
2760            },
2761        )
2762        .unwrap();
2763        assert_eq!(slice.len(), 1);
2764        assert_eq!(slice[0]["case"], json!("c2"));
2765
2766        cleanup(&pkg);
2767    }
2768
2769    #[test]
2770    fn get_by_alias_roundtrip() {
2771        let pkg = unique_pkg();
2772        let (id, _) = create(json!({
2773            "pkg": { "name": pkg },
2774            "stats": { "pass_rate": 0.85 }
2775        }))
2776        .unwrap();
2777
2778        let alias_name = format!("best_{pkg}");
2779        alias_set(&alias_name, &id, Some(&pkg), None).unwrap();
2780
2781        let card = get_by_alias(&alias_name).unwrap().unwrap();
2782        assert_eq!(card["card_id"], json!(id));
2783        assert_eq!(card["stats"]["pass_rate"], json!(0.85));
2784
2785        assert!(get_by_alias("nonexistent_alias_xyz").unwrap().is_none());
2786
2787        cleanup(&pkg);
2788    }
2789
2790    #[test]
2791    fn samples_errors_on_missing_card() {
2792        let err = write_samples("does_not_exist_xyz_samples", vec![json!({})]).unwrap_err();
2793        assert!(err.contains("not found"));
2794    }
2795
2796    // ─── import_from_dir ───────────────────────────────────────
2797
2798    #[test]
2799    fn import_from_dir_copies_cards() {
2800        let pkg = unique_pkg();
2801        let tmp = tempfile::tempdir().unwrap();
2802
2803        // Create a source card file
2804        let card_id = format!("{pkg}_imported");
2805        let card_content = format!(
2806            "schema_version = \"{SCHEMA_VERSION}\"\ncard_id = \"{card_id}\"\npkg = \"{pkg}\"\n"
2807        );
2808        fs::write(tmp.path().join(format!("{card_id}.toml")), &card_content).unwrap();
2809
2810        // Create a matching samples file
2811        fs::write(
2812            tmp.path().join(format!("{card_id}.samples.jsonl")),
2813            "{\"case\":\"c0\"}\n",
2814        )
2815        .unwrap();
2816
2817        let (imported, skipped) = import_from_dir(tmp.path(), &pkg).unwrap();
2818        assert_eq!(imported, vec![card_id.clone()]);
2819        assert!(skipped.is_empty());
2820
2821        // Verify card was imported
2822        let got = get(&card_id).unwrap().unwrap();
2823        assert_eq!(got["card_id"], json!(card_id));
2824
2825        // Verify samples were copied
2826        let samples = read_samples(&card_id, SamplesQuery::default()).unwrap();
2827        assert_eq!(samples.len(), 1);
2828
2829        cleanup(&pkg);
2830    }
2831
2832    #[test]
2833    fn import_from_dir_skips_existing() {
2834        let pkg = unique_pkg();
2835        // Create a card in the store first
2836        let (existing_id, _) = create(json!({
2837            "pkg": { "name": pkg },
2838            "stats": { "pass_rate": 0.5 }
2839        }))
2840        .unwrap();
2841
2842        // Try to import a card with the same id
2843        let tmp = tempfile::tempdir().unwrap();
2844        let card_content = format!(
2845            "schema_version = \"{SCHEMA_VERSION}\"\ncard_id = \"{existing_id}\"\npkg = \"{pkg}\"\n"
2846        );
2847        fs::write(
2848            tmp.path().join(format!("{existing_id}.toml")),
2849            &card_content,
2850        )
2851        .unwrap();
2852
2853        let (imported, skipped) = import_from_dir(tmp.path(), &pkg).unwrap();
2854        assert!(imported.is_empty());
2855        assert_eq!(skipped, vec![existing_id.clone()]);
2856
2857        // Original card untouched
2858        let got = get(&existing_id).unwrap().unwrap();
2859        assert_eq!(got["stats"]["pass_rate"], json!(0.5));
2860
2861        cleanup(&pkg);
2862    }
2863
2864    #[test]
2865    fn import_from_dir_skips_non_card_toml() {
2866        let pkg = unique_pkg();
2867        let tmp = tempfile::tempdir().unwrap();
2868
2869        // A TOML file without schema_version = "card/v0" should be skipped
2870        fs::write(tmp.path().join("not_a_card.toml"), "title = \"hello\"\n").unwrap();
2871
2872        let (imported, skipped) = import_from_dir(tmp.path(), &pkg).unwrap();
2873        assert!(imported.is_empty());
2874        assert!(skipped.is_empty());
2875
2876        cleanup(&pkg);
2877    }
2878}