1use std::fs;
35use std::path::PathBuf;
36
37use serde_json::{json, Value as Json};
38
39pub const SCHEMA_VERSION: &str = "card/v0";
40
41fn 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
51fn 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
73fn 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
82fn 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
94fn 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
138fn short_model(id: &str) -> String {
141 if id.is_empty() {
142 return "model".into();
143 }
144 let stripped = id
146 .strip_prefix("claude-")
147 .or_else(|| id.strip_prefix("gpt-"))
148 .unwrap_or(id);
149 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
161fn 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
176fn 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
195fn 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
210fn 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
248fn 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
267fn 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
279pub 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 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 if let Some(params) = obj.get("params").cloned() {
297 if params.is_object() {
298 let fp = djb2_hex(&stable_json(¶ms));
299 obj.insert("param_fingerprint".to_string(), json!(fp));
300 }
301 }
302
303 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 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
340fn 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
358pub 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#[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
439pub 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 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
497pub 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
547fn 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
646pub 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
675pub 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
695pub 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#[derive(Debug, Default, Clone)]
710pub struct FindQuery {
711 pub pkg: Option<String>,
712 pub scenario: Option<String>,
713 pub model: Option<String>,
714 pub sort: Option<String>,
716 pub limit: Option<usize>,
717 pub min_pass_rate: Option<f64>,
718}
719
720pub 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
760fn 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
780pub 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
808pub 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 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); 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 let tail = id.strip_prefix(&format!("{pkg}_")).unwrap();
969 let parts: Vec<&str> = tail.split('_').collect();
970 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 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 #[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 let got = get(&id).unwrap().unwrap();
1041 assert_eq!(got["caveats"]["notes"], json!("rescored after fix"));
1042 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 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 #[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 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 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 #[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 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 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 #[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 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}