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)
345        .map_err(|e| format!("Failed to read cards dir: {e}"))?;
346    for entry in entries.flatten() {
347        let p = entry.path();
348        if p.is_dir() {
349            let candidate = p.join(format!("{card_id}.toml"));
350            if candidate.exists() {
351                return Ok(Some(candidate));
352            }
353        }
354    }
355    Ok(None)
356}
357
358/// Read a Card by id. Returns None if not found.
359pub fn get(card_id: &str) -> Result<Option<Json>, String> {
360    let path = match find_card_path(card_id)? {
361        Some(p) => p,
362        None => return Ok(None),
363    };
364    let text = fs::read_to_string(&path)
365        .map_err(|e| format!("Failed to read card '{card_id}': {e}"))?;
366    let val: toml::Value = toml::from_str(&text)
367        .map_err(|e| format!("Failed to parse card '{card_id}': {e}"))?;
368    Ok(Some(toml_to_json(val)))
369}
370
371/// Summary row for `alc.card.list()`.
372#[derive(Debug, Clone)]
373pub struct Summary {
374    pub card_id: String,
375    pub pkg: String,
376    pub created_at: Option<String>,
377    pub model: Option<String>,
378    pub scenario: Option<String>,
379    pub pass_rate: Option<f64>,
380}
381
382impl Summary {
383    fn to_json(&self) -> Json {
384        let mut m = serde_json::Map::new();
385        m.insert("card_id".into(), json!(self.card_id));
386        m.insert("pkg".into(), json!(self.pkg));
387        if let Some(v) = &self.created_at {
388            m.insert("created_at".into(), json!(v));
389        }
390        if let Some(v) = &self.model {
391            m.insert("model".into(), json!(v));
392        }
393        if let Some(v) = &self.scenario {
394            m.insert("scenario".into(), json!(v));
395        }
396        if let Some(v) = self.pass_rate {
397            m.insert("pass_rate".into(), json!(v));
398        }
399        Json::Object(m)
400    }
401}
402
403fn summarize(path: &std::path::Path, pkg: &str) -> Option<Summary> {
404    let text = fs::read_to_string(path).ok()?;
405    let val: toml::Value = toml::from_str(&text).ok()?;
406    let card_id = val
407        .get("card_id")
408        .and_then(|v| v.as_str())
409        .or_else(|| path.file_stem().and_then(|s| s.to_str()))?
410        .to_string();
411    let created_at = val
412        .get("created_at")
413        .and_then(|v| v.as_str())
414        .map(String::from);
415    let model = val
416        .get("model")
417        .and_then(|m| m.get("id"))
418        .and_then(|v| v.as_str())
419        .map(String::from);
420    let scenario = val
421        .get("scenario")
422        .and_then(|s| s.get("name"))
423        .and_then(|v| v.as_str())
424        .map(String::from);
425    let pass_rate = val
426        .get("stats")
427        .and_then(|s| s.get("pass_rate"))
428        .and_then(|v| v.as_float());
429    Some(Summary {
430        card_id,
431        pkg: pkg.to_string(),
432        created_at,
433        model,
434        scenario,
435        pass_rate,
436    })
437}
438
439/// List cards. `pkg_filter = Some("name")` restricts to that pkg subdir.
440pub fn list(pkg_filter: Option<&str>) -> Result<Vec<Summary>, String> {
441    let root = cards_dir()?;
442    let mut out = Vec::new();
443
444    let pkg_dirs: Vec<PathBuf> = if let Some(p) = pkg_filter {
445        validate_name(p, "pkg")?;
446        let d = root.join(p);
447        if d.is_dir() {
448            vec![d]
449        } else {
450            vec![]
451        }
452    } else {
453        fs::read_dir(&root)
454            .map_err(|e| format!("Failed to read cards dir: {e}"))?
455            .flatten()
456            .map(|e| e.path())
457            .filter(|p| p.is_dir())
458            .collect()
459    };
460
461    for pdir in pkg_dirs {
462        let pkg = pdir
463            .file_name()
464            .and_then(|s| s.to_str())
465            .unwrap_or("")
466            .to_string();
467        let entries = match fs::read_dir(&pdir) {
468            Ok(e) => e,
469            Err(_) => continue,
470        };
471        for entry in entries.flatten() {
472            let p = entry.path();
473            if p.extension().and_then(|s| s.to_str()) != Some("toml") {
474                continue;
475            }
476            if let Some(s) = summarize(&p, &pkg) {
477                out.push(s);
478            }
479        }
480    }
481
482    // Sort newest first. card_id embeds a compact UTC timestamp so it's
483    // naturally chronological; we still prefer created_at when present
484    // (some callers may override it), falling back to card_id.
485    out.sort_by(|a, b| {
486        b.created_at
487            .cmp(&a.created_at)
488            .then_with(|| b.card_id.cmp(&a.card_id))
489    });
490    Ok(out)
491}
492
493pub fn summaries_to_json(rows: &[Summary]) -> Json {
494    Json::Array(rows.iter().map(|s| s.to_json()).collect())
495}
496
497// ───────────────────────────────────────────────────────────────
498// P1 API: append / alias_{set,list} / find
499// ───────────────────────────────────────────────────────────────
500
501/// Append new top-level fields to an existing Card.
502///
503/// Semantics: **additive only**. If any top-level key in `fields` already
504/// exists in the Card, the call fails — Cards are immutable w.r.t. existing
505/// data. New top-level keys are inserted and the Card file is rewritten
506/// atomically.
507///
508/// Returns the merged Card JSON.
509pub fn append(card_id: &str, fields: Json) -> Result<Json, String> {
510    let path = find_card_path(card_id)?
511        .ok_or_else(|| format!("alc.card.append: card '{card_id}' not found"))?;
512    let fields_obj = match fields {
513        Json::Object(m) => m,
514        _ => return Err("alc.card.append: fields must be a table".into()),
515    };
516
517    let text = fs::read_to_string(&path)
518        .map_err(|e| format!("Failed to read card '{card_id}': {e}"))?;
519    let existing: toml::Value = toml::from_str(&text)
520        .map_err(|e| format!("Failed to parse card '{card_id}': {e}"))?;
521    let mut existing_json = toml_to_json(existing);
522    let existing_obj = existing_json
523        .as_object_mut()
524        .ok_or_else(|| format!("Card '{card_id}' is not a table"))?;
525
526    for (k, v) in fields_obj {
527        if existing_obj.contains_key(&k) {
528            return Err(format!(
529                "alc.card.append: key '{k}' already set on card '{card_id}' (immutable)"
530            ));
531        }
532        if !v.is_null() {
533            existing_obj.insert(k, v);
534        }
535    }
536
537    let toml_val = json_to_toml(existing_json.clone())?;
538    let text = toml::to_string_pretty(&toml_val)
539        .map_err(|e| format!("Failed to serialize card TOML: {e}"))?;
540    let tmp = path.with_extension("toml.tmp");
541    fs::write(&tmp, &text).map_err(|e| format!("Failed to write card tmp: {e}"))?;
542    fs::rename(&tmp, &path).map_err(|e| format!("Failed to rename card file: {e}"))?;
543
544    Ok(existing_json)
545}
546
547/// Path of the global alias table: `~/.algocline/cards/_aliases.toml`.
548fn aliases_path() -> Result<PathBuf, String> {
549    Ok(cards_dir()?.join("_aliases.toml"))
550}
551
552#[derive(Debug, Clone)]
553pub struct Alias {
554    pub name: String,
555    pub card_id: String,
556    pub pkg: Option<String>,
557    pub set_at: String,
558    pub note: Option<String>,
559}
560
561impl Alias {
562    fn to_json(&self) -> Json {
563        let mut m = serde_json::Map::new();
564        m.insert("name".into(), json!(self.name));
565        m.insert("card_id".into(), json!(self.card_id));
566        if let Some(p) = &self.pkg {
567            m.insert("pkg".into(), json!(p));
568        }
569        m.insert("set_at".into(), json!(self.set_at));
570        if let Some(n) = &self.note {
571            m.insert("note".into(), json!(n));
572        }
573        Json::Object(m)
574    }
575}
576
577fn read_aliases() -> Result<Vec<Alias>, String> {
578    let path = aliases_path()?;
579    if !path.exists() {
580        return Ok(Vec::new());
581    }
582    let text = fs::read_to_string(&path)
583        .map_err(|e| format!("Failed to read aliases file: {e}"))?;
584    let val: toml::Value = toml::from_str(&text)
585        .map_err(|e| format!("Failed to parse aliases file: {e}"))?;
586    let arr = val
587        .get("alias")
588        .and_then(|v| v.as_array())
589        .cloned()
590        .unwrap_or_default();
591    let mut out = Vec::with_capacity(arr.len());
592    for entry in arr {
593        let t = match entry {
594            toml::Value::Table(t) => t,
595            _ => continue,
596        };
597        let name = match t.get("name").and_then(|v| v.as_str()) {
598            Some(s) => s.to_string(),
599            None => continue,
600        };
601        let card_id = match t.get("card_id").and_then(|v| v.as_str()) {
602            Some(s) => s.to_string(),
603            None => continue,
604        };
605        out.push(Alias {
606            name,
607            card_id,
608            pkg: t.get("pkg").and_then(|v| v.as_str()).map(String::from),
609            set_at: t
610                .get("set_at")
611                .and_then(|v| v.as_str())
612                .map(String::from)
613                .unwrap_or_default(),
614            note: t.get("note").and_then(|v| v.as_str()).map(String::from),
615        });
616    }
617    Ok(out)
618}
619
620fn write_aliases(aliases: &[Alias]) -> Result<(), String> {
621    let path = aliases_path()?;
622    let mut arr = Vec::with_capacity(aliases.len());
623    for a in aliases {
624        let mut t = toml::map::Map::new();
625        t.insert("name".into(), toml::Value::String(a.name.clone()));
626        t.insert("card_id".into(), toml::Value::String(a.card_id.clone()));
627        if let Some(p) = &a.pkg {
628            t.insert("pkg".into(), toml::Value::String(p.clone()));
629        }
630        t.insert("set_at".into(), toml::Value::String(a.set_at.clone()));
631        if let Some(n) = &a.note {
632            t.insert("note".into(), toml::Value::String(n.clone()));
633        }
634        arr.push(toml::Value::Table(t));
635    }
636    let mut root = toml::map::Map::new();
637    root.insert("alias".into(), toml::Value::Array(arr));
638    let text = toml::to_string_pretty(&toml::Value::Table(root))
639        .map_err(|e| format!("Failed to serialize aliases: {e}"))?;
640    let tmp = path.with_extension("toml.tmp");
641    fs::write(&tmp, &text).map_err(|e| format!("Failed to write aliases tmp: {e}"))?;
642    fs::rename(&tmp, &path).map_err(|e| format!("Failed to rename aliases file: {e}"))?;
643    Ok(())
644}
645
646/// Bind (or rebind) an alias to a Card.
647///
648/// Validates that `card_id` exists. If an alias with the same `name` already
649/// exists it is overwritten — the alias table is intentionally mutable even
650/// though the Cards themselves are not.
651pub fn alias_set(
652    name: &str,
653    card_id: &str,
654    pkg: Option<&str>,
655    note: Option<&str>,
656) -> Result<Alias, String> {
657    validate_name(name, "alias")?;
658    if find_card_path(card_id)?.is_none() {
659        return Err(format!("alc.card.alias_set: card '{card_id}' not found"));
660    }
661    let mut aliases = read_aliases()?;
662    aliases.retain(|a| a.name != name);
663    let entry = Alias {
664        name: name.to_string(),
665        card_id: card_id.to_string(),
666        pkg: pkg.map(String::from),
667        set_at: now_rfc3339(),
668        note: note.map(String::from),
669    };
670    aliases.push(entry.clone());
671    write_aliases(&aliases)?;
672    Ok(entry)
673}
674
675/// Resolve an alias name to its bound Card and return the full Card JSON.
676///
677/// Shortcut for `alias_list → filter → get`. Returns `None` when the alias
678/// does not exist. Errors when the alias points at a missing Card — that
679/// would indicate a corrupt alias table (the target was deleted out of band).
680pub fn get_by_alias(name: &str) -> Result<Option<Json>, String> {
681    validate_name(name, "alias")?;
682    let aliases = read_aliases()?;
683    let Some(alias) = aliases.into_iter().find(|a| a.name == name) else {
684        return Ok(None);
685    };
686    match get(&alias.card_id)? {
687        Some(card) => Ok(Some(card)),
688        None => Err(format!(
689            "alc.card.get_by_alias: alias '{name}' points at missing card '{}'",
690            alias.card_id
691        )),
692    }
693}
694
695/// List aliases, optionally filtered by pkg.
696pub fn alias_list(pkg_filter: Option<&str>) -> Result<Vec<Alias>, String> {
697    let mut aliases = read_aliases()?;
698    if let Some(p) = pkg_filter {
699        aliases.retain(|a| a.pkg.as_deref() == Some(p));
700    }
701    Ok(aliases)
702}
703
704pub fn aliases_to_json(rows: &[Alias]) -> Json {
705    Json::Array(rows.iter().map(|a| a.to_json()).collect())
706}
707
708/// Query parameters for `find`. All filters are optional.
709#[derive(Debug, Default, Clone)]
710pub struct FindQuery {
711    pub pkg: Option<String>,
712    pub scenario: Option<String>,
713    pub model: Option<String>,
714    /// One of: `"pass_rate"` (desc), `"pass_rate_asc"`, `"created_at"` (desc, default).
715    pub sort: Option<String>,
716    pub limit: Option<usize>,
717    pub min_pass_rate: Option<f64>,
718}
719
720/// Filter/sort Cards across the store.
721///
722/// Thin layer over `list`: loads all summaries (optionally restricted to
723/// a pkg subdir), applies field filters, sorts, and truncates.
724pub fn find(q: FindQuery) -> Result<Vec<Summary>, String> {
725    let mut rows = list(q.pkg.as_deref())?;
726    if let Some(s) = &q.scenario {
727        rows.retain(|r| r.scenario.as_deref() == Some(s.as_str()));
728    }
729    if let Some(m) = &q.model {
730        rows.retain(|r| r.model.as_deref() == Some(m.as_str()));
731    }
732    if let Some(min) = q.min_pass_rate {
733        rows.retain(|r| r.pass_rate.is_some_and(|v| v >= min));
734    }
735    match q.sort.as_deref() {
736        Some("pass_rate") => rows.sort_by(|a, b| {
737            b.pass_rate
738                .partial_cmp(&a.pass_rate)
739                .unwrap_or(std::cmp::Ordering::Equal)
740        }),
741        Some("pass_rate_asc") => rows.sort_by(|a, b| {
742            a.pass_rate
743                .partial_cmp(&b.pass_rate)
744                .unwrap_or(std::cmp::Ordering::Equal)
745        }),
746        _ => {
747            rows.sort_by(|a, b| {
748                b.created_at
749                    .cmp(&a.created_at)
750                    .then_with(|| b.card_id.cmp(&a.card_id))
751            });
752        }
753    }
754    if let Some(lim) = q.limit {
755        rows.truncate(lim);
756    }
757    Ok(rows)
758}
759
760// ───────────────────────────────────────────────────────────────
761// Samples sidecar: per-case detail written alongside a Card as
762// `{pkg}/{card_id}.samples.jsonl`. Write-once to preserve Card
763// immutability: once a Card has a samples file, it cannot be
764// rewritten — mismatched per-case data would break auditability.
765// ───────────────────────────────────────────────────────────────
766
767/// Resolve the samples sidecar path for a Card.
768///
769/// Returns an error if the Card does not exist — samples without a
770/// parent Card are meaningless and we refuse to create orphans.
771fn samples_path(card_id: &str) -> Result<PathBuf, String> {
772    let card_path = find_card_path(card_id)?
773        .ok_or_else(|| format!("card '{card_id}' not found"))?;
774    let dir = card_path
775        .parent()
776        .ok_or_else(|| format!("card '{card_id}' has no parent directory"))?;
777    Ok(dir.join(format!("{card_id}.samples.jsonl")))
778}
779
780/// Write per-case samples to `{card_id}.samples.jsonl` (write-once).
781///
782/// Each `samples` entry is serialized as one compact JSON line.
783/// Fails if a samples file already exists for this card — mirrors
784/// the immutability guarantee of Cards themselves.
785pub fn write_samples(card_id: &str, samples: Vec<Json>) -> Result<PathBuf, String> {
786    let path = samples_path(card_id)?;
787    if path.exists() {
788        return Err(format!(
789            "alc.card.write_samples: samples already exist for card '{card_id}' (write-once)"
790        ));
791    }
792    let mut buf = String::new();
793    for (idx, s) in samples.iter().enumerate() {
794        let line = serde_json::to_string(s).map_err(|e| {
795            format!("alc.card.write_samples: failed to serialize sample #{idx}: {e}")
796        })?;
797        buf.push_str(&line);
798        buf.push('\n');
799    }
800    let tmp = path.with_extension("jsonl.tmp");
801    fs::write(&tmp, &buf)
802        .map_err(|e| format!("Failed to write samples tmp: {e}"))?;
803    fs::rename(&tmp, &path)
804        .map_err(|e| format!("Failed to rename samples file: {e}"))?;
805    Ok(path)
806}
807
808/// Read per-case samples from `{card_id}.samples.jsonl`.
809///
810/// Returns an empty Vec if no samples file exists (Cards without
811/// per-case details are the common case, not an error).
812pub fn read_samples(
813    card_id: &str,
814    offset: usize,
815    limit: Option<usize>,
816) -> Result<Vec<Json>, String> {
817    let path = samples_path(card_id)?;
818    if !path.exists() {
819        return Ok(Vec::new());
820    }
821    let text = fs::read_to_string(&path)
822        .map_err(|e| format!("Failed to read samples file: {e}"))?;
823    let mut out = Vec::new();
824    for (i, line) in text.lines().enumerate() {
825        if line.trim().is_empty() {
826            continue;
827        }
828        if i < offset {
829            continue;
830        }
831        if let Some(lim) = limit {
832            if out.len() >= lim {
833                break;
834            }
835        }
836        let val: Json = serde_json::from_str(line)
837            .map_err(|e| format!("Failed to parse sample line {i}: {e}"))?;
838        out.push(val);
839    }
840    Ok(out)
841}
842
843#[cfg(test)]
844mod tests {
845    use super::*;
846
847    fn unique_pkg() -> String {
848        let ns = std::time::SystemTime::now()
849            .duration_since(std::time::UNIX_EPOCH)
850            .unwrap()
851            .as_nanos();
852        format!("_test_card_{ns}")
853    }
854
855    fn cleanup(pkg: &str) {
856        if let Ok(d) = pkg_dir(pkg) {
857            let _ = fs::remove_dir_all(&d);
858        }
859    }
860
861    #[test]
862    fn minimum_valid_card() {
863        let pkg = unique_pkg();
864        let input = json!({ "pkg": { "name": pkg } });
865        let (id, path) = create(input).unwrap();
866        assert!(path.exists());
867        assert!(id.starts_with(&pkg));
868
869        let got = get(&id).unwrap().unwrap();
870        assert_eq!(got["schema_version"], json!(SCHEMA_VERSION));
871        assert_eq!(got["card_id"], json!(id));
872        assert_eq!(got["pkg"]["name"], json!(pkg));
873        assert!(got.get("created_at").is_some());
874        assert!(got.get("created_by").is_some());
875
876        cleanup(&pkg);
877    }
878
879    #[test]
880    fn create_rejects_missing_pkg_name() {
881        let err = create(json!({})).unwrap_err();
882        assert!(err.contains("pkg.name"));
883    }
884
885    #[test]
886    fn create_is_immutable() {
887        let pkg = unique_pkg();
888        let input = json!({
889            "card_id": "fixed_id_001",
890            "pkg": { "name": pkg }
891        });
892        create(input.clone()).unwrap();
893        let err = create(input).unwrap_err();
894        assert!(err.contains("already exists"));
895        cleanup(&pkg);
896    }
897
898    #[test]
899    fn create_injects_param_fingerprint() {
900        let pkg = unique_pkg();
901        let input = json!({
902            "pkg": { "name": pkg },
903            "params": { "depth": 3, "temperature": 0.0 }
904        });
905        let (id, _) = create(input).unwrap();
906        let got = get(&id).unwrap().unwrap();
907        assert!(got["param_fingerprint"].is_string());
908        cleanup(&pkg);
909    }
910
911    #[test]
912    fn list_returns_newest_first() {
913        let pkg = unique_pkg();
914        // First card
915        let (id1, _) = create(json!({
916            "card_id": format!("{pkg}_a"),
917            "pkg": { "name": pkg },
918            "created_at": "2025-01-01T00:00:00Z"
919        }))
920        .unwrap();
921        let (id2, _) = create(json!({
922            "card_id": format!("{pkg}_b"),
923            "pkg": { "name": pkg },
924            "created_at": "2026-01-01T00:00:00Z"
925        }))
926        .unwrap();
927
928        let rows = list(Some(&pkg)).unwrap();
929        assert_eq!(rows.len(), 2);
930        assert_eq!(rows[0].card_id, id2); // newer first
931        assert_eq!(rows[1].card_id, id1);
932
933        cleanup(&pkg);
934    }
935
936    #[test]
937    fn list_extracts_summary_fields() {
938        let pkg = unique_pkg();
939        let (id, _) = create(json!({
940            "pkg": { "name": pkg },
941            "model": { "id": "claude-opus-4-6" },
942            "scenario": { "name": "gsm8k_sample100" },
943            "stats": { "pass_rate": 0.82 }
944        }))
945        .unwrap();
946
947        let rows = list(Some(&pkg)).unwrap();
948        let row = rows.iter().find(|r| r.card_id == id).unwrap();
949        assert_eq!(row.model.as_deref(), Some("claude-opus-4-6"));
950        assert_eq!(row.scenario.as_deref(), Some("gsm8k_sample100"));
951        assert_eq!(row.pass_rate, Some(0.82));
952
953        cleanup(&pkg);
954    }
955
956    #[test]
957    fn get_missing_returns_none() {
958        assert!(get("does_not_exist_xyz").unwrap().is_none());
959    }
960
961    #[test]
962    fn card_id_embeds_compact_timestamp() {
963        let pkg = unique_pkg();
964        let (id, _) = create(json!({ "pkg": { "name": pkg } })).unwrap();
965        // Expect: {pkg}_{model}_{YYYYMMDDTHHMMSS}_{hash6}
966        // After removing the pkg prefix, there should be a segment
967        // containing 'T' separating date and time.
968        let tail = id.strip_prefix(&format!("{pkg}_")).unwrap();
969        let parts: Vec<&str> = tail.split('_').collect();
970        // parts = [model_short, YYYYMMDDTHHMMSS, hash6]
971        assert_eq!(parts.len(), 3, "unexpected card_id shape: {id}");
972        let ts = parts[1];
973        assert_eq!(ts.len(), 15, "timestamp segment wrong length: {ts}");
974        assert!(ts.chars().nth(8) == Some('T'), "missing T separator: {ts}");
975        cleanup(&pkg);
976    }
977
978    #[test]
979    fn now_compact_format() {
980        let s = now_compact();
981        assert_eq!(s.len(), 15);
982        assert_eq!(s.chars().nth(8), Some('T'));
983        // All other positions are digits
984        for (i, c) in s.chars().enumerate() {
985            if i != 8 {
986                assert!(c.is_ascii_digit(), "non-digit at pos {i}: {s}");
987            }
988        }
989    }
990
991    #[test]
992    fn short_model_variants() {
993        assert_eq!(short_model("claude-opus-4-6"), "opus46");
994        assert_eq!(short_model("gpt-4o"), "4o");
995        assert_eq!(short_model(""), "model");
996    }
997
998    #[test]
999    fn two_cards_same_second_different_stats_get_distinct_ids() {
1000        let pkg = unique_pkg();
1001        let input1 = json!({
1002            "pkg": { "name": pkg },
1003            "scenario": { "name": "gsm8k" },
1004            "stats": { "pass_rate": 0.4 }
1005        });
1006        let input2 = json!({
1007            "pkg": { "name": pkg },
1008            "scenario": { "name": "gsm8k" },
1009            "stats": { "pass_rate": 0.9 }
1010        });
1011        let (id1, _) = create(input1).unwrap();
1012        let (id2, _) = create(input2).unwrap();
1013        assert_ne!(id1, id2, "distinct stats must yield distinct card_ids");
1014        cleanup(&pkg);
1015    }
1016
1017    // ─── P1: append ────────────────────────────────────────────
1018
1019    #[test]
1020    fn append_adds_new_fields() {
1021        let pkg = unique_pkg();
1022        let (id, _) = create(json!({
1023            "pkg": { "name": pkg },
1024            "stats": { "pass_rate": 0.5 }
1025        }))
1026        .unwrap();
1027
1028        let merged = append(
1029            &id,
1030            json!({
1031                "caveats": { "notes": "rescored after fix" },
1032                "metadata": { "reviewer": "yn" }
1033            }),
1034        )
1035        .unwrap();
1036        assert_eq!(merged["caveats"]["notes"], json!("rescored after fix"));
1037        assert_eq!(merged["metadata"]["reviewer"], json!("yn"));
1038
1039        // Persisted
1040        let got = get(&id).unwrap().unwrap();
1041        assert_eq!(got["caveats"]["notes"], json!("rescored after fix"));
1042        // Existing field untouched
1043        assert_eq!(got["stats"]["pass_rate"], json!(0.5));
1044
1045        cleanup(&pkg);
1046    }
1047
1048    #[test]
1049    fn append_rejects_existing_key() {
1050        let pkg = unique_pkg();
1051        let (id, _) = create(json!({
1052            "pkg": { "name": pkg },
1053            "stats": { "pass_rate": 0.5 }
1054        }))
1055        .unwrap();
1056
1057        let err = append(&id, json!({ "stats": { "pass_rate": 0.9 } })).unwrap_err();
1058        assert!(err.contains("already set"), "got: {err}");
1059        // Verify original value still there
1060        let got = get(&id).unwrap().unwrap();
1061        assert_eq!(got["stats"]["pass_rate"], json!(0.5));
1062
1063        cleanup(&pkg);
1064    }
1065
1066    #[test]
1067    fn append_errors_on_missing_card() {
1068        let err = append("does_not_exist_xyz", json!({ "x": 1 })).unwrap_err();
1069        assert!(err.contains("not found"));
1070    }
1071
1072    // ─── P1: alias_set / alias_list ────────────────────────────
1073
1074    #[test]
1075    fn alias_set_and_list_roundtrip() {
1076        let pkg = unique_pkg();
1077        let (id, _) = create(json!({ "pkg": { "name": pkg } })).unwrap();
1078
1079        let alias_name = format!("test_alias_{}", &pkg);
1080        alias_set(&alias_name, &id, Some(&pkg), Some("smoke")).unwrap();
1081
1082        let rows = alias_list(Some(&pkg)).unwrap();
1083        let a = rows.iter().find(|a| a.name == alias_name).unwrap();
1084        assert_eq!(a.card_id, id);
1085        assert_eq!(a.pkg.as_deref(), Some(pkg.as_str()));
1086        assert_eq!(a.note.as_deref(), Some("smoke"));
1087        assert!(!a.set_at.is_empty());
1088
1089        // Rebind to a new card
1090        let (id2, _) = create(json!({
1091            "card_id": format!("{pkg}_b"),
1092            "pkg": { "name": pkg }
1093        }))
1094        .unwrap();
1095        alias_set(&alias_name, &id2, Some(&pkg), None).unwrap();
1096        let rows = alias_list(Some(&pkg)).unwrap();
1097        let matching: Vec<&Alias> = rows.iter().filter(|a| a.name == alias_name).collect();
1098        assert_eq!(matching.len(), 1, "alias should be unique by name");
1099        assert_eq!(matching[0].card_id, id2);
1100
1101        // Cleanup: remove our alias from the file
1102        let remaining: Vec<Alias> = read_aliases()
1103            .unwrap()
1104            .into_iter()
1105            .filter(|a| a.name != alias_name)
1106            .collect();
1107        write_aliases(&remaining).unwrap();
1108        cleanup(&pkg);
1109    }
1110
1111    #[test]
1112    fn alias_set_rejects_unknown_card() {
1113        let err = alias_set("x", "does_not_exist_xyz", None, None).unwrap_err();
1114        assert!(err.contains("not found"));
1115    }
1116
1117    // ─── P1: find ──────────────────────────────────────────────
1118
1119    #[test]
1120    fn find_filters_and_sorts_by_pass_rate() {
1121        let pkg = unique_pkg();
1122        create(json!({
1123            "card_id": format!("{pkg}_low"),
1124            "pkg": { "name": pkg },
1125            "scenario": { "name": "gsm8k" },
1126            "stats": { "pass_rate": 0.4 }
1127        }))
1128        .unwrap();
1129        create(json!({
1130            "card_id": format!("{pkg}_high"),
1131            "pkg": { "name": pkg },
1132            "scenario": { "name": "gsm8k" },
1133            "stats": { "pass_rate": 0.9 }
1134        }))
1135        .unwrap();
1136        create(json!({
1137            "card_id": format!("{pkg}_other"),
1138            "pkg": { "name": pkg },
1139            "scenario": { "name": "other" },
1140            "stats": { "pass_rate": 1.0 }
1141        }))
1142        .unwrap();
1143
1144        let rows = find(FindQuery {
1145            pkg: Some(pkg.clone()),
1146            scenario: Some("gsm8k".into()),
1147            sort: Some("pass_rate".into()),
1148            ..Default::default()
1149        })
1150        .unwrap();
1151        assert_eq!(rows.len(), 2);
1152        assert_eq!(rows[0].pass_rate, Some(0.9));
1153        assert_eq!(rows[1].pass_rate, Some(0.4));
1154
1155        // min_pass_rate filter
1156        let rows = find(FindQuery {
1157            pkg: Some(pkg.clone()),
1158            min_pass_rate: Some(0.8),
1159            sort: Some("pass_rate".into()),
1160            ..Default::default()
1161        })
1162        .unwrap();
1163        assert_eq!(rows.len(), 2);
1164        assert!(rows.iter().all(|r| r.pass_rate.unwrap() >= 0.8));
1165
1166        // limit
1167        let rows = find(FindQuery {
1168            pkg: Some(pkg.clone()),
1169            sort: Some("pass_rate".into()),
1170            limit: Some(1),
1171            ..Default::default()
1172        })
1173        .unwrap();
1174        assert_eq!(rows.len(), 1);
1175        assert_eq!(rows[0].pass_rate, Some(1.0));
1176
1177        cleanup(&pkg);
1178    }
1179
1180    // ─── samples sidecar ───────────────────────────────────────
1181
1182    #[test]
1183    fn write_and_read_samples_roundtrip() {
1184        let pkg = unique_pkg();
1185        let (id, _) = create(json!({
1186            "pkg": { "name": pkg },
1187            "stats": { "pass_rate": 0.5 }
1188        }))
1189        .unwrap();
1190
1191        let samples = vec![
1192            json!({ "case": "c0", "passed": true, "score": 1.0 }),
1193            json!({ "case": "c1", "passed": false, "score": 0.0 }),
1194            json!({ "case": "c2", "passed": true, "score": 0.75 }),
1195        ];
1196        let path = write_samples(&id, samples.clone()).unwrap();
1197        assert!(path.exists());
1198        assert!(path.to_string_lossy().ends_with(".samples.jsonl"));
1199
1200        let got = read_samples(&id, 0, None).unwrap();
1201        assert_eq!(got.len(), 3);
1202        assert_eq!(got[0]["case"], json!("c0"));
1203        assert_eq!(got[2]["score"], json!(0.75));
1204
1205        // offset + limit
1206        let slice = read_samples(&id, 1, Some(1)).unwrap();
1207        assert_eq!(slice.len(), 1);
1208        assert_eq!(slice[0]["case"], json!("c1"));
1209
1210        cleanup(&pkg);
1211    }
1212
1213    #[test]
1214    fn write_samples_is_write_once() {
1215        let pkg = unique_pkg();
1216        let (id, _) = create(json!({ "pkg": { "name": pkg } })).unwrap();
1217        write_samples(&id, vec![json!({ "x": 1 })]).unwrap();
1218        let err = write_samples(&id, vec![json!({ "x": 2 })]).unwrap_err();
1219        assert!(err.contains("already exist"), "got: {err}");
1220        cleanup(&pkg);
1221    }
1222
1223    #[test]
1224    fn read_samples_empty_when_absent() {
1225        let pkg = unique_pkg();
1226        let (id, _) = create(json!({ "pkg": { "name": pkg } })).unwrap();
1227        let got = read_samples(&id, 0, None).unwrap();
1228        assert!(got.is_empty());
1229        cleanup(&pkg);
1230    }
1231
1232    #[test]
1233    fn get_by_alias_roundtrip() {
1234        let pkg = unique_pkg();
1235        let (id, _) = create(json!({
1236            "pkg": { "name": pkg },
1237            "stats": { "pass_rate": 0.85 }
1238        }))
1239        .unwrap();
1240
1241        let alias_name = format!("best_{pkg}");
1242        alias_set(&alias_name, &id, Some(&pkg), None).unwrap();
1243
1244        let card = get_by_alias(&alias_name).unwrap().unwrap();
1245        assert_eq!(card["card_id"], json!(id));
1246        assert_eq!(card["stats"]["pass_rate"], json!(0.85));
1247
1248        assert!(get_by_alias("nonexistent_alias_xyz").unwrap().is_none());
1249
1250        cleanup(&pkg);
1251    }
1252
1253    #[test]
1254    fn samples_errors_on_missing_card() {
1255        let err = write_samples("does_not_exist_xyz_samples", vec![json!({})]).unwrap_err();
1256        assert!(err.contains("not found"));
1257    }
1258}