Skip to main content

algocline_engine/
card.rs

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