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).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
357pub 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#[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
438pub 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 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
496pub 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
546fn 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
645pub 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
674pub 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
694pub 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#[derive(Debug, Default, Clone)]
709pub struct FindQuery {
710 pub pkg: Option<String>,
711 pub scenario: Option<String>,
712 pub model: Option<String>,
713 pub sort: Option<String>,
715 pub limit: Option<usize>,
716 pub min_pass_rate: Option<f64>,
717}
718
719pub 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
759pub 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 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 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; }
818
819 fs::copy(&path, &dest_toml).map_err(|e| format!("Failed to copy card '{fname}': {e}"))?;
821
822 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
839fn 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
852pub 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
878pub 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 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); 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 let tail = id.strip_prefix(&format!("{pkg}_")).unwrap();
1039 let parts: Vec<&str> = tail.split('_').collect();
1040 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 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 #[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 let got = get(&id).unwrap().unwrap();
1111 assert_eq!(got["caveats"]["notes"], json!("rescored after fix"));
1112 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 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 #[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 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 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 #[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 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 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 #[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 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 #[test]
1332 fn import_from_dir_copies_cards() {
1333 let pkg = unique_pkg();
1334 let tmp = tempfile::tempdir().unwrap();
1335
1336 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 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 let got = get(&card_id).unwrap().unwrap();
1356 assert_eq!(got["card_id"], json!(card_id));
1357
1358 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 let (existing_id, _) = create(json!({
1370 "pkg": { "name": pkg },
1371 "stats": { "pass_rate": 0.5 }
1372 }))
1373 .unwrap();
1374
1375 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 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 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}