1use std::fs;
85use std::path::PathBuf;
86
87use serde_json::{json, Value as Json};
88
89pub const SCHEMA_VERSION: &str = "card/v0";
90
91fn cards_dir() -> Result<PathBuf, String> {
93 let home = dirs::home_dir().ok_or("Cannot determine home directory")?;
94 let dir = home.join(".algocline").join("cards");
95 if !dir.exists() {
96 fs::create_dir_all(&dir).map_err(|e| format!("Failed to create cards dir: {e}"))?;
97 }
98 Ok(dir)
99}
100
101fn pkg_dir(pkg: &str) -> Result<PathBuf, String> {
103 validate_name(pkg, "pkg")?;
104 let dir = cards_dir()?.join(pkg);
105 if !dir.exists() {
106 fs::create_dir_all(&dir).map_err(|e| format!("Failed to create pkg dir: {e}"))?;
107 }
108 Ok(dir)
109}
110
111fn validate_name(name: &str, kind: &str) -> Result<(), String> {
112 if name.is_empty()
113 || name.contains('/')
114 || name.contains('\\')
115 || name.contains("..")
116 || name.contains('\0')
117 {
118 return Err(format!("Invalid {kind} name: '{name}'"));
119 }
120 Ok(())
121}
122
123fn djb2_hex(s: &str) -> String {
125 let mut h: u64 = 5381;
126 for b in s.bytes() {
127 h = h.wrapping_mul(33).wrapping_add(b as u64);
128 }
129 format!("{h:016x}")
130}
131
132fn hash6(s: &str) -> String {
139 let hex = djb2_hex(s);
140 let start = hex.len().saturating_sub(6);
141 hex[start..].to_string()
142}
143
144fn stable_json(v: &Json) -> String {
146 let mut buf = String::new();
147 stable_json_into(v, &mut buf);
148 buf
149}
150fn stable_json_into(v: &Json, buf: &mut String) {
151 match v {
152 Json::Null => buf.push_str("null"),
153 Json::Bool(b) => buf.push_str(if *b { "true" } else { "false" }),
154 Json::Number(n) => buf.push_str(&n.to_string()),
155 Json::String(s) => {
156 buf.push('"');
157 buf.push_str(s);
158 buf.push('"');
159 }
160 Json::Array(a) => {
161 buf.push('[');
162 for (i, item) in a.iter().enumerate() {
163 if i > 0 {
164 buf.push(',');
165 }
166 stable_json_into(item, buf);
167 }
168 buf.push(']');
169 }
170 Json::Object(m) => {
171 let mut keys: Vec<&String> = m.keys().collect();
172 keys.sort();
173 buf.push('{');
174 for (i, k) in keys.iter().enumerate() {
175 if i > 0 {
176 buf.push(',');
177 }
178 buf.push('"');
179 buf.push_str(k);
180 buf.push_str("\":");
181 stable_json_into(&m[*k], buf);
182 }
183 buf.push('}');
184 }
185 }
186}
187
188fn short_model(id: &str) -> String {
191 if id.is_empty() {
192 return "model".into();
193 }
194 let stripped = id
196 .strip_prefix("claude-")
197 .or_else(|| id.strip_prefix("gpt-"))
198 .unwrap_or(id);
199 let s: String = stripped
201 .chars()
202 .filter(|c| c.is_ascii_alphanumeric())
203 .collect();
204 if s.is_empty() {
205 "model".into()
206 } else {
207 s
208 }
209}
210
211fn now_rfc3339() -> String {
213 let secs = std::time::SystemTime::now()
214 .duration_since(std::time::UNIX_EPOCH)
215 .map(|d| d.as_secs())
216 .unwrap_or(0) as i64;
217 let days = secs.div_euclid(86400);
218 let tod = secs.rem_euclid(86400);
219 let (y, mo, d) = civil_from_days(days);
220 let hh = tod / 3600;
221 let mm = (tod % 3600) / 60;
222 let ss = tod % 60;
223 format!("{y:04}-{mo:02}-{d:02}T{hh:02}:{mm:02}:{ss:02}Z")
224}
225
226fn now_compact() -> String {
232 let secs = std::time::SystemTime::now()
233 .duration_since(std::time::UNIX_EPOCH)
234 .map(|d| d.as_secs())
235 .unwrap_or(0) as i64;
236 let days = secs.div_euclid(86400);
237 let tod = secs.rem_euclid(86400);
238 let (y, mo, d) = civil_from_days(days);
239 let hh = tod / 3600;
240 let mm = (tod % 3600) / 60;
241 let ss = tod % 60;
242 format!("{y:04}{mo:02}{d:02}T{hh:02}{mm:02}{ss:02}")
243}
244
245fn civil_from_days(z: i64) -> (i32, u32, u32) {
247 let z = z + 719468;
248 let era = if z >= 0 { z } else { z - 146096 } / 146097;
249 let doe = (z - era * 146097) as u64;
250 let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365;
251 let y = yoe as i64 + era * 400;
252 let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
253 let mp = (5 * doy + 2) / 153;
254 let d = (doy - (153 * mp + 2) / 5 + 1) as u32;
255 let m = (if mp < 10 { mp + 3 } else { mp - 9 }) as u32;
256 let y = y + if m <= 2 { 1 } else { 0 };
257 (y as i32, m, d)
258}
259
260fn json_to_toml(v: Json) -> Result<toml::Value, String> {
263 Ok(match v {
264 Json::Null => return Err("TOML does not support null values".into()),
265 Json::Bool(b) => toml::Value::Boolean(b),
266 Json::Number(n) => {
267 if let Some(i) = n.as_i64() {
268 toml::Value::Integer(i)
269 } else if let Some(f) = n.as_f64() {
270 toml::Value::Float(f)
271 } else {
272 return Err(format!("Unsupported number: {n}"));
273 }
274 }
275 Json::String(s) => toml::Value::String(s),
276 Json::Array(a) => {
277 let mut out = Vec::with_capacity(a.len());
278 for item in a {
279 if !item.is_null() {
280 out.push(json_to_toml(item)?);
281 }
282 }
283 toml::Value::Array(out)
284 }
285 Json::Object(m) => {
286 let mut table = toml::map::Map::new();
287 for (k, val) in m {
288 if val.is_null() {
289 continue;
290 }
291 table.insert(k, json_to_toml(val)?);
292 }
293 toml::Value::Table(table)
294 }
295 })
296}
297
298fn toml_to_json(v: toml::Value) -> Json {
300 match v {
301 toml::Value::String(s) => Json::String(s),
302 toml::Value::Integer(i) => json!(i),
303 toml::Value::Float(f) => json!(f),
304 toml::Value::Boolean(b) => Json::Bool(b),
305 toml::Value::Datetime(dt) => Json::String(dt.to_string()),
306 toml::Value::Array(a) => Json::Array(a.into_iter().map(toml_to_json).collect()),
307 toml::Value::Table(t) => {
308 let mut m = serde_json::Map::new();
309 for (k, v) in t {
310 m.insert(k, toml_to_json(v));
311 }
312 Json::Object(m)
313 }
314 }
315}
316
317fn require_pkg_name(input: &Json) -> Result<String, String> {
319 let name = input
320 .get("pkg")
321 .and_then(|p| p.get("name"))
322 .and_then(|n| n.as_str())
323 .ok_or_else(|| "alc.card.create: pkg.name is required".to_string())?
324 .to_string();
325 validate_name(&name, "pkg")?;
326 Ok(name)
327}
328
329pub fn create(mut input: Json) -> Result<(String, PathBuf), String> {
331 if !input.is_object() {
332 return Err("alc.card.create: input must be a table".into());
333 }
334 let pkg_name = require_pkg_name(&input)?;
335 let obj = input.as_object_mut().unwrap();
336
337 obj.entry("schema_version".to_string())
339 .or_insert_with(|| json!(SCHEMA_VERSION));
340 obj.entry("created_at".to_string())
341 .or_insert_with(|| json!(now_rfc3339()));
342 obj.entry("created_by".to_string())
343 .or_insert_with(|| json!(format!("alc@{}", env!("CARGO_PKG_VERSION"))));
344
345 if let Some(params) = obj.get("params").cloned() {
347 if params.is_object() {
348 let fp = djb2_hex(&stable_json(¶ms));
349 obj.insert("param_fingerprint".to_string(), json!(fp));
350 }
351 }
352
353 let card_id = match obj.get("card_id").and_then(|v| v.as_str()) {
355 Some(id) if !id.is_empty() => id.to_string(),
356 _ => {
357 let model_id = obj
358 .get("model")
359 .and_then(|m| m.get("id"))
360 .and_then(|v| v.as_str())
361 .unwrap_or("");
362 let model_short = short_model(model_id);
363 let ts = now_compact();
364 let fp_seed = stable_json(&Json::Object(obj.clone()));
365 let h = hash6(&fp_seed);
366 format!("{pkg_name}_{model_short}_{ts}_{h}")
367 }
368 };
369 validate_name(&card_id, "card_id")?;
370 obj.insert("card_id".to_string(), json!(card_id.clone()));
371
372 let dir = pkg_dir(&pkg_name)?;
374 let path = dir.join(format!("{card_id}.toml"));
375 if path.exists() {
376 return Err(format!(
377 "alc.card.create: card '{card_id}' already exists (immutable)"
378 ));
379 }
380 let toml_val = json_to_toml(input)?;
381 let text = toml::to_string_pretty(&toml_val)
382 .map_err(|e| format!("Failed to serialize card TOML: {e}"))?;
383 let tmp = path.with_extension("toml.tmp");
384 fs::write(&tmp, &text).map_err(|e| format!("Failed to write card tmp: {e}"))?;
385 fs::rename(&tmp, &path).map_err(|e| format!("Failed to rename card file: {e}"))?;
386
387 Ok((card_id, path))
388}
389
390fn find_card_path(card_id: &str) -> Result<Option<PathBuf>, String> {
392 validate_name(card_id, "card_id")?;
393 let root = cards_dir()?;
394 let entries = fs::read_dir(&root).map_err(|e| format!("Failed to read cards dir: {e}"))?;
395 for entry in entries.flatten() {
396 let p = entry.path();
397 if p.is_dir() {
398 let candidate = p.join(format!("{card_id}.toml"));
399 if candidate.exists() {
400 return Ok(Some(candidate));
401 }
402 }
403 }
404 Ok(None)
405}
406
407pub fn get(card_id: &str) -> Result<Option<Json>, String> {
409 let path = match find_card_path(card_id)? {
410 Some(p) => p,
411 None => return Ok(None),
412 };
413 let text =
414 fs::read_to_string(&path).map_err(|e| format!("Failed to read card '{card_id}': {e}"))?;
415 let val: toml::Value =
416 toml::from_str(&text).map_err(|e| format!("Failed to parse card '{card_id}': {e}"))?;
417 Ok(Some(toml_to_json(val)))
418}
419
420#[derive(Debug, Clone)]
422pub struct Summary {
423 pub card_id: String,
424 pub pkg: String,
425 pub created_at: Option<String>,
426 pub model: Option<String>,
427 pub scenario: Option<String>,
428 pub pass_rate: Option<f64>,
429}
430
431impl Summary {
432 fn to_json(&self) -> Json {
433 let mut m = serde_json::Map::new();
434 m.insert("card_id".into(), json!(self.card_id));
435 m.insert("pkg".into(), json!(self.pkg));
436 if let Some(v) = &self.created_at {
437 m.insert("created_at".into(), json!(v));
438 }
439 if let Some(v) = &self.model {
440 m.insert("model".into(), json!(v));
441 }
442 if let Some(v) = &self.scenario {
443 m.insert("scenario".into(), json!(v));
444 }
445 if let Some(v) = self.pass_rate {
446 m.insert("pass_rate".into(), json!(v));
447 }
448 Json::Object(m)
449 }
450}
451
452fn summarize(path: &std::path::Path, pkg: &str) -> Option<Summary> {
453 let text = fs::read_to_string(path).ok()?;
454 let val: toml::Value = toml::from_str(&text).ok()?;
455 let card_id = val
456 .get("card_id")
457 .and_then(|v| v.as_str())
458 .or_else(|| path.file_stem().and_then(|s| s.to_str()))?
459 .to_string();
460 let created_at = val
461 .get("created_at")
462 .and_then(|v| v.as_str())
463 .map(String::from);
464 let model = val
465 .get("model")
466 .and_then(|m| m.get("id"))
467 .and_then(|v| v.as_str())
468 .map(String::from);
469 let scenario = val
470 .get("scenario")
471 .and_then(|s| s.get("name"))
472 .and_then(|v| v.as_str())
473 .map(String::from);
474 let pass_rate = val
475 .get("stats")
476 .and_then(|s| s.get("pass_rate"))
477 .and_then(|v| v.as_float());
478 Some(Summary {
479 card_id,
480 pkg: pkg.to_string(),
481 created_at,
482 model,
483 scenario,
484 pass_rate,
485 })
486}
487
488pub fn list(pkg_filter: Option<&str>) -> Result<Vec<Summary>, String> {
490 let root = cards_dir()?;
491 let mut out = Vec::new();
492
493 let pkg_dirs: Vec<PathBuf> = if let Some(p) = pkg_filter {
494 validate_name(p, "pkg")?;
495 let d = root.join(p);
496 if d.is_dir() {
497 vec![d]
498 } else {
499 vec![]
500 }
501 } else {
502 fs::read_dir(&root)
503 .map_err(|e| format!("Failed to read cards dir: {e}"))?
504 .flatten()
505 .map(|e| e.path())
506 .filter(|p| p.is_dir())
507 .collect()
508 };
509
510 for pdir in pkg_dirs {
511 let pkg = pdir
512 .file_name()
513 .and_then(|s| s.to_str())
514 .unwrap_or("")
515 .to_string();
516 let entries = match fs::read_dir(&pdir) {
517 Ok(e) => e,
518 Err(_) => continue,
519 };
520 for entry in entries.flatten() {
521 let p = entry.path();
522 if p.extension().and_then(|s| s.to_str()) != Some("toml") {
523 continue;
524 }
525 if let Some(s) = summarize(&p, &pkg) {
526 out.push(s);
527 }
528 }
529 }
530
531 out.sort_by(|a, b| {
535 b.created_at
536 .cmp(&a.created_at)
537 .then_with(|| b.card_id.cmp(&a.card_id))
538 });
539 Ok(out)
540}
541
542pub fn summaries_to_json(rows: &[Summary]) -> Json {
543 Json::Array(rows.iter().map(|s| s.to_json()).collect())
544}
545
546pub fn append(card_id: &str, fields: Json) -> Result<Json, String> {
559 let path = find_card_path(card_id)?
560 .ok_or_else(|| format!("alc.card.append: card '{card_id}' not found"))?;
561 let fields_obj = match fields {
562 Json::Object(m) => m,
563 _ => return Err("alc.card.append: fields must be a table".into()),
564 };
565
566 let text =
567 fs::read_to_string(&path).map_err(|e| format!("Failed to read card '{card_id}': {e}"))?;
568 let existing: toml::Value =
569 toml::from_str(&text).map_err(|e| format!("Failed to parse card '{card_id}': {e}"))?;
570 let mut existing_json = toml_to_json(existing);
571 let existing_obj = existing_json
572 .as_object_mut()
573 .ok_or_else(|| format!("Card '{card_id}' is not a table"))?;
574
575 for (k, v) in fields_obj {
576 if existing_obj.contains_key(&k) {
577 return Err(format!(
578 "alc.card.append: key '{k}' already set on card '{card_id}' (immutable)"
579 ));
580 }
581 if !v.is_null() {
582 existing_obj.insert(k, v);
583 }
584 }
585
586 let toml_val = json_to_toml(existing_json.clone())?;
587 let text = toml::to_string_pretty(&toml_val)
588 .map_err(|e| format!("Failed to serialize card TOML: {e}"))?;
589 let tmp = path.with_extension("toml.tmp");
590 fs::write(&tmp, &text).map_err(|e| format!("Failed to write card tmp: {e}"))?;
591 fs::rename(&tmp, &path).map_err(|e| format!("Failed to rename card file: {e}"))?;
592
593 Ok(existing_json)
594}
595
596fn aliases_path() -> Result<PathBuf, String> {
598 Ok(cards_dir()?.join("_aliases.toml"))
599}
600
601#[derive(Debug, Clone)]
602pub struct Alias {
603 pub name: String,
604 pub card_id: String,
605 pub pkg: Option<String>,
606 pub set_at: String,
607 pub note: Option<String>,
608}
609
610impl Alias {
611 fn to_json(&self) -> Json {
612 let mut m = serde_json::Map::new();
613 m.insert("name".into(), json!(self.name));
614 m.insert("card_id".into(), json!(self.card_id));
615 if let Some(p) = &self.pkg {
616 m.insert("pkg".into(), json!(p));
617 }
618 m.insert("set_at".into(), json!(self.set_at));
619 if let Some(n) = &self.note {
620 m.insert("note".into(), json!(n));
621 }
622 Json::Object(m)
623 }
624}
625
626fn read_aliases() -> Result<Vec<Alias>, String> {
627 let path = aliases_path()?;
628 if !path.exists() {
629 return Ok(Vec::new());
630 }
631 let text =
632 fs::read_to_string(&path).map_err(|e| format!("Failed to read aliases file: {e}"))?;
633 let val: toml::Value =
634 toml::from_str(&text).map_err(|e| format!("Failed to parse aliases file: {e}"))?;
635 let arr = val
636 .get("alias")
637 .and_then(|v| v.as_array())
638 .cloned()
639 .unwrap_or_default();
640 let mut out = Vec::with_capacity(arr.len());
641 for entry in arr {
642 let t = match entry {
643 toml::Value::Table(t) => t,
644 _ => continue,
645 };
646 let name = match t.get("name").and_then(|v| v.as_str()) {
647 Some(s) => s.to_string(),
648 None => continue,
649 };
650 let card_id = match t.get("card_id").and_then(|v| v.as_str()) {
651 Some(s) => s.to_string(),
652 None => continue,
653 };
654 out.push(Alias {
655 name,
656 card_id,
657 pkg: t.get("pkg").and_then(|v| v.as_str()).map(String::from),
658 set_at: t
659 .get("set_at")
660 .and_then(|v| v.as_str())
661 .map(String::from)
662 .unwrap_or_default(),
663 note: t.get("note").and_then(|v| v.as_str()).map(String::from),
664 });
665 }
666 Ok(out)
667}
668
669fn write_aliases(aliases: &[Alias]) -> Result<(), String> {
670 let path = aliases_path()?;
671 let mut arr = Vec::with_capacity(aliases.len());
672 for a in aliases {
673 let mut t = toml::map::Map::new();
674 t.insert("name".into(), toml::Value::String(a.name.clone()));
675 t.insert("card_id".into(), toml::Value::String(a.card_id.clone()));
676 if let Some(p) = &a.pkg {
677 t.insert("pkg".into(), toml::Value::String(p.clone()));
678 }
679 t.insert("set_at".into(), toml::Value::String(a.set_at.clone()));
680 if let Some(n) = &a.note {
681 t.insert("note".into(), toml::Value::String(n.clone()));
682 }
683 arr.push(toml::Value::Table(t));
684 }
685 let mut root = toml::map::Map::new();
686 root.insert("alias".into(), toml::Value::Array(arr));
687 let text = toml::to_string_pretty(&toml::Value::Table(root))
688 .map_err(|e| format!("Failed to serialize aliases: {e}"))?;
689 let tmp = path.with_extension("toml.tmp");
690 fs::write(&tmp, &text).map_err(|e| format!("Failed to write aliases tmp: {e}"))?;
691 fs::rename(&tmp, &path).map_err(|e| format!("Failed to rename aliases file: {e}"))?;
692 Ok(())
693}
694
695pub fn alias_set(
701 name: &str,
702 card_id: &str,
703 pkg: Option<&str>,
704 note: Option<&str>,
705) -> Result<Alias, String> {
706 validate_name(name, "alias")?;
707 if find_card_path(card_id)?.is_none() {
708 return Err(format!("alc.card.alias_set: card '{card_id}' not found"));
709 }
710 let mut aliases = read_aliases()?;
711 aliases.retain(|a| a.name != name);
712 let entry = Alias {
713 name: name.to_string(),
714 card_id: card_id.to_string(),
715 pkg: pkg.map(String::from),
716 set_at: now_rfc3339(),
717 note: note.map(String::from),
718 };
719 aliases.push(entry.clone());
720 write_aliases(&aliases)?;
721 Ok(entry)
722}
723
724pub fn get_by_alias(name: &str) -> Result<Option<Json>, String> {
730 validate_name(name, "alias")?;
731 let aliases = read_aliases()?;
732 let Some(alias) = aliases.into_iter().find(|a| a.name == name) else {
733 return Ok(None);
734 };
735 match get(&alias.card_id)? {
736 Some(card) => Ok(Some(card)),
737 None => Err(format!(
738 "alc.card.get_by_alias: alias '{name}' points at missing card '{}'",
739 alias.card_id
740 )),
741 }
742}
743
744pub fn alias_list(pkg_filter: Option<&str>) -> Result<Vec<Alias>, String> {
746 let mut aliases = read_aliases()?;
747 if let Some(p) = pkg_filter {
748 aliases.retain(|a| a.pkg.as_deref() == Some(p));
749 }
750 Ok(aliases)
751}
752
753pub fn aliases_to_json(rows: &[Alias]) -> Json {
754 Json::Array(rows.iter().map(|a| a.to_json()).collect())
755}
756
757#[derive(Debug, Default, Clone)]
759pub struct FindQuery {
760 pub pkg: Option<String>,
761 pub scenario: Option<String>,
762 pub model: Option<String>,
763 pub sort: Option<String>,
765 pub limit: Option<usize>,
766 pub min_pass_rate: Option<f64>,
767}
768
769pub fn find(q: FindQuery) -> Result<Vec<Summary>, String> {
774 let mut rows = list(q.pkg.as_deref())?;
775 if let Some(s) = &q.scenario {
776 rows.retain(|r| r.scenario.as_deref() == Some(s.as_str()));
777 }
778 if let Some(m) = &q.model {
779 rows.retain(|r| r.model.as_deref() == Some(m.as_str()));
780 }
781 if let Some(min) = q.min_pass_rate {
782 rows.retain(|r| r.pass_rate.is_some_and(|v| v >= min));
783 }
784 match q.sort.as_deref() {
785 Some("pass_rate") => rows.sort_by(|a, b| {
786 b.pass_rate
787 .partial_cmp(&a.pass_rate)
788 .unwrap_or(std::cmp::Ordering::Equal)
789 }),
790 Some("pass_rate_asc") => rows.sort_by(|a, b| {
791 a.pass_rate
792 .partial_cmp(&b.pass_rate)
793 .unwrap_or(std::cmp::Ordering::Equal)
794 }),
795 _ => {
796 rows.sort_by(|a, b| {
797 b.created_at
798 .cmp(&a.created_at)
799 .then_with(|| b.card_id.cmp(&a.card_id))
800 });
801 }
802 }
803 if let Some(lim) = q.limit {
804 rows.truncate(lim);
805 }
806 Ok(rows)
807}
808
809pub fn import_from_dir(
829 source_dir: &std::path::Path,
830 pkg: &str,
831) -> Result<(Vec<String>, Vec<String>), String> {
832 validate_name(pkg, "pkg")?;
833 let dest = pkg_dir(pkg)?;
834 let mut imported = Vec::new();
835 let mut skipped = Vec::new();
836
837 let entries =
838 fs::read_dir(source_dir).map_err(|e| format!("Failed to read card source dir: {e}"))?;
839
840 for entry in entries.flatten() {
841 let path = entry.path();
842 let fname = match path.file_name().and_then(|n| n.to_str()) {
843 Some(n) => n.to_string(),
844 None => continue,
845 };
846
847 if !fname.ends_with(".toml") {
849 continue;
850 }
851
852 let card_id = fname.trim_end_matches(".toml");
853 let dest_toml = dest.join(&fname);
854
855 if dest_toml.exists() {
856 skipped.push(card_id.to_string());
857 continue;
858 }
859
860 let text = fs::read_to_string(&path)
862 .map_err(|e| format!("Failed to read card file '{fname}': {e}"))?;
863 let val: toml::Value = toml::from_str(&text)
864 .map_err(|e| format!("Failed to parse card file '{fname}': {e}"))?;
865 if val.get("schema_version").and_then(|v| v.as_str()) != Some(SCHEMA_VERSION) {
866 continue; }
868
869 fs::copy(&path, &dest_toml).map_err(|e| format!("Failed to copy card '{fname}': {e}"))?;
871
872 let samples_name = format!("{card_id}.samples.jsonl");
874 let samples_src = source_dir.join(&samples_name);
875 if samples_src.exists() {
876 let samples_dest = dest.join(&samples_name);
877 if !samples_dest.exists() {
878 fs::copy(&samples_src, &samples_dest)
879 .map_err(|e| format!("Failed to copy samples '{samples_name}': {e}"))?;
880 }
881 }
882
883 imported.push(card_id.to_string());
884 }
885
886 Ok((imported, skipped))
887}
888
889fn samples_path(card_id: &str) -> Result<PathBuf, String> {
894 let card_path =
895 find_card_path(card_id)?.ok_or_else(|| format!("card '{card_id}' not found"))?;
896 let dir = card_path
897 .parent()
898 .ok_or_else(|| format!("card '{card_id}' has no parent directory"))?;
899 Ok(dir.join(format!("{card_id}.samples.jsonl")))
900}
901
902pub fn write_samples(card_id: &str, samples: Vec<Json>) -> Result<PathBuf, String> {
908 let path = samples_path(card_id)?;
909 if path.exists() {
910 return Err(format!(
911 "alc.card.write_samples: samples already exist for card '{card_id}' (write-once)"
912 ));
913 }
914 let mut buf = String::new();
915 for (idx, s) in samples.iter().enumerate() {
916 let line = serde_json::to_string(s).map_err(|e| {
917 format!("alc.card.write_samples: failed to serialize sample #{idx}: {e}")
918 })?;
919 buf.push_str(&line);
920 buf.push('\n');
921 }
922 let tmp = path.with_extension("jsonl.tmp");
923 fs::write(&tmp, &buf).map_err(|e| format!("Failed to write samples tmp: {e}"))?;
924 fs::rename(&tmp, &path).map_err(|e| format!("Failed to rename samples file: {e}"))?;
925 Ok(path)
926}
927
928pub fn read_samples(
933 card_id: &str,
934 offset: usize,
935 limit: Option<usize>,
936) -> Result<Vec<Json>, String> {
937 let path = samples_path(card_id)?;
938 if !path.exists() {
939 return Ok(Vec::new());
940 }
941 let text =
942 fs::read_to_string(&path).map_err(|e| format!("Failed to read samples file: {e}"))?;
943 let mut out = Vec::new();
944 for (i, line) in text.lines().enumerate() {
945 if line.trim().is_empty() {
946 continue;
947 }
948 if i < offset {
949 continue;
950 }
951 if let Some(lim) = limit {
952 if out.len() >= lim {
953 break;
954 }
955 }
956 let val: Json = serde_json::from_str(line)
957 .map_err(|e| format!("Failed to parse sample line {i}: {e}"))?;
958 out.push(val);
959 }
960 Ok(out)
961}
962
963#[cfg(test)]
964mod tests {
965 use super::*;
966
967 fn unique_pkg() -> String {
968 let ns = std::time::SystemTime::now()
969 .duration_since(std::time::UNIX_EPOCH)
970 .unwrap()
971 .as_nanos();
972 format!("_test_card_{ns}")
973 }
974
975 fn cleanup(pkg: &str) {
976 if let Ok(d) = pkg_dir(pkg) {
977 let _ = fs::remove_dir_all(&d);
978 }
979 }
980
981 #[test]
982 fn minimum_valid_card() {
983 let pkg = unique_pkg();
984 let input = json!({ "pkg": { "name": pkg } });
985 let (id, path) = create(input).unwrap();
986 assert!(path.exists());
987 assert!(id.starts_with(&pkg));
988
989 let got = get(&id).unwrap().unwrap();
990 assert_eq!(got["schema_version"], json!(SCHEMA_VERSION));
991 assert_eq!(got["card_id"], json!(id));
992 assert_eq!(got["pkg"]["name"], json!(pkg));
993 assert!(got.get("created_at").is_some());
994 assert!(got.get("created_by").is_some());
995
996 cleanup(&pkg);
997 }
998
999 #[test]
1000 fn create_rejects_missing_pkg_name() {
1001 let err = create(json!({})).unwrap_err();
1002 assert!(err.contains("pkg.name"));
1003 }
1004
1005 #[test]
1006 fn create_is_immutable() {
1007 let pkg = unique_pkg();
1008 let input = json!({
1009 "card_id": "fixed_id_001",
1010 "pkg": { "name": pkg }
1011 });
1012 create(input.clone()).unwrap();
1013 let err = create(input).unwrap_err();
1014 assert!(err.contains("already exists"));
1015 cleanup(&pkg);
1016 }
1017
1018 #[test]
1019 fn create_injects_param_fingerprint() {
1020 let pkg = unique_pkg();
1021 let input = json!({
1022 "pkg": { "name": pkg },
1023 "params": { "depth": 3, "temperature": 0.0 }
1024 });
1025 let (id, _) = create(input).unwrap();
1026 let got = get(&id).unwrap().unwrap();
1027 assert!(got["param_fingerprint"].is_string());
1028 cleanup(&pkg);
1029 }
1030
1031 #[test]
1032 fn list_returns_newest_first() {
1033 let pkg = unique_pkg();
1034 let (id1, _) = create(json!({
1036 "card_id": format!("{pkg}_a"),
1037 "pkg": { "name": pkg },
1038 "created_at": "2025-01-01T00:00:00Z"
1039 }))
1040 .unwrap();
1041 let (id2, _) = create(json!({
1042 "card_id": format!("{pkg}_b"),
1043 "pkg": { "name": pkg },
1044 "created_at": "2026-01-01T00:00:00Z"
1045 }))
1046 .unwrap();
1047
1048 let rows = list(Some(&pkg)).unwrap();
1049 assert_eq!(rows.len(), 2);
1050 assert_eq!(rows[0].card_id, id2); assert_eq!(rows[1].card_id, id1);
1052
1053 cleanup(&pkg);
1054 }
1055
1056 #[test]
1057 fn list_extracts_summary_fields() {
1058 let pkg = unique_pkg();
1059 let (id, _) = create(json!({
1060 "pkg": { "name": pkg },
1061 "model": { "id": "claude-opus-4-6" },
1062 "scenario": { "name": "gsm8k_sample100" },
1063 "stats": { "pass_rate": 0.82 }
1064 }))
1065 .unwrap();
1066
1067 let rows = list(Some(&pkg)).unwrap();
1068 let row = rows.iter().find(|r| r.card_id == id).unwrap();
1069 assert_eq!(row.model.as_deref(), Some("claude-opus-4-6"));
1070 assert_eq!(row.scenario.as_deref(), Some("gsm8k_sample100"));
1071 assert_eq!(row.pass_rate, Some(0.82));
1072
1073 cleanup(&pkg);
1074 }
1075
1076 #[test]
1077 fn get_missing_returns_none() {
1078 assert!(get("does_not_exist_xyz").unwrap().is_none());
1079 }
1080
1081 #[test]
1082 fn card_id_embeds_compact_timestamp() {
1083 let pkg = unique_pkg();
1084 let (id, _) = create(json!({ "pkg": { "name": pkg } })).unwrap();
1085 let tail = id.strip_prefix(&format!("{pkg}_")).unwrap();
1089 let parts: Vec<&str> = tail.split('_').collect();
1090 assert_eq!(parts.len(), 3, "unexpected card_id shape: {id}");
1092 let ts = parts[1];
1093 assert_eq!(ts.len(), 15, "timestamp segment wrong length: {ts}");
1094 assert!(ts.chars().nth(8) == Some('T'), "missing T separator: {ts}");
1095 cleanup(&pkg);
1096 }
1097
1098 #[test]
1099 fn now_compact_format() {
1100 let s = now_compact();
1101 assert_eq!(s.len(), 15);
1102 assert_eq!(s.chars().nth(8), Some('T'));
1103 for (i, c) in s.chars().enumerate() {
1105 if i != 8 {
1106 assert!(c.is_ascii_digit(), "non-digit at pos {i}: {s}");
1107 }
1108 }
1109 }
1110
1111 #[test]
1112 fn short_model_variants() {
1113 assert_eq!(short_model("claude-opus-4-6"), "opus46");
1114 assert_eq!(short_model("gpt-4o"), "4o");
1115 assert_eq!(short_model(""), "model");
1116 }
1117
1118 #[test]
1119 fn two_cards_same_second_different_stats_get_distinct_ids() {
1120 let pkg = unique_pkg();
1121 let input1 = json!({
1122 "pkg": { "name": pkg },
1123 "scenario": { "name": "gsm8k" },
1124 "stats": { "pass_rate": 0.4 }
1125 });
1126 let input2 = json!({
1127 "pkg": { "name": pkg },
1128 "scenario": { "name": "gsm8k" },
1129 "stats": { "pass_rate": 0.9 }
1130 });
1131 let (id1, _) = create(input1).unwrap();
1132 let (id2, _) = create(input2).unwrap();
1133 assert_ne!(id1, id2, "distinct stats must yield distinct card_ids");
1134 cleanup(&pkg);
1135 }
1136
1137 #[test]
1140 fn append_adds_new_fields() {
1141 let pkg = unique_pkg();
1142 let (id, _) = create(json!({
1143 "pkg": { "name": pkg },
1144 "stats": { "pass_rate": 0.5 }
1145 }))
1146 .unwrap();
1147
1148 let merged = append(
1149 &id,
1150 json!({
1151 "caveats": { "notes": "rescored after fix" },
1152 "metadata": { "reviewer": "yn" }
1153 }),
1154 )
1155 .unwrap();
1156 assert_eq!(merged["caveats"]["notes"], json!("rescored after fix"));
1157 assert_eq!(merged["metadata"]["reviewer"], json!("yn"));
1158
1159 let got = get(&id).unwrap().unwrap();
1161 assert_eq!(got["caveats"]["notes"], json!("rescored after fix"));
1162 assert_eq!(got["stats"]["pass_rate"], json!(0.5));
1164
1165 cleanup(&pkg);
1166 }
1167
1168 #[test]
1169 fn append_rejects_existing_key() {
1170 let pkg = unique_pkg();
1171 let (id, _) = create(json!({
1172 "pkg": { "name": pkg },
1173 "stats": { "pass_rate": 0.5 }
1174 }))
1175 .unwrap();
1176
1177 let err = append(&id, json!({ "stats": { "pass_rate": 0.9 } })).unwrap_err();
1178 assert!(err.contains("already set"), "got: {err}");
1179 let got = get(&id).unwrap().unwrap();
1181 assert_eq!(got["stats"]["pass_rate"], json!(0.5));
1182
1183 cleanup(&pkg);
1184 }
1185
1186 #[test]
1187 fn append_errors_on_missing_card() {
1188 let err = append("does_not_exist_xyz", json!({ "x": 1 })).unwrap_err();
1189 assert!(err.contains("not found"));
1190 }
1191
1192 #[test]
1195 fn alias_set_and_list_roundtrip() {
1196 let pkg = unique_pkg();
1197 let (id, _) = create(json!({ "pkg": { "name": pkg } })).unwrap();
1198
1199 let alias_name = format!("test_alias_{}", &pkg);
1200 alias_set(&alias_name, &id, Some(&pkg), Some("smoke")).unwrap();
1201
1202 let rows = alias_list(Some(&pkg)).unwrap();
1203 let a = rows.iter().find(|a| a.name == alias_name).unwrap();
1204 assert_eq!(a.card_id, id);
1205 assert_eq!(a.pkg.as_deref(), Some(pkg.as_str()));
1206 assert_eq!(a.note.as_deref(), Some("smoke"));
1207 assert!(!a.set_at.is_empty());
1208
1209 let (id2, _) = create(json!({
1211 "card_id": format!("{pkg}_b"),
1212 "pkg": { "name": pkg }
1213 }))
1214 .unwrap();
1215 alias_set(&alias_name, &id2, Some(&pkg), None).unwrap();
1216 let rows = alias_list(Some(&pkg)).unwrap();
1217 let matching: Vec<&Alias> = rows.iter().filter(|a| a.name == alias_name).collect();
1218 assert_eq!(matching.len(), 1, "alias should be unique by name");
1219 assert_eq!(matching[0].card_id, id2);
1220
1221 let remaining: Vec<Alias> = read_aliases()
1223 .unwrap()
1224 .into_iter()
1225 .filter(|a| a.name != alias_name)
1226 .collect();
1227 write_aliases(&remaining).unwrap();
1228 cleanup(&pkg);
1229 }
1230
1231 #[test]
1232 fn alias_set_rejects_unknown_card() {
1233 let err = alias_set("x", "does_not_exist_xyz", None, None).unwrap_err();
1234 assert!(err.contains("not found"));
1235 }
1236
1237 #[test]
1240 fn find_filters_and_sorts_by_pass_rate() {
1241 let pkg = unique_pkg();
1242 create(json!({
1243 "card_id": format!("{pkg}_low"),
1244 "pkg": { "name": pkg },
1245 "scenario": { "name": "gsm8k" },
1246 "stats": { "pass_rate": 0.4 }
1247 }))
1248 .unwrap();
1249 create(json!({
1250 "card_id": format!("{pkg}_high"),
1251 "pkg": { "name": pkg },
1252 "scenario": { "name": "gsm8k" },
1253 "stats": { "pass_rate": 0.9 }
1254 }))
1255 .unwrap();
1256 create(json!({
1257 "card_id": format!("{pkg}_other"),
1258 "pkg": { "name": pkg },
1259 "scenario": { "name": "other" },
1260 "stats": { "pass_rate": 1.0 }
1261 }))
1262 .unwrap();
1263
1264 let rows = find(FindQuery {
1265 pkg: Some(pkg.clone()),
1266 scenario: Some("gsm8k".into()),
1267 sort: Some("pass_rate".into()),
1268 ..Default::default()
1269 })
1270 .unwrap();
1271 assert_eq!(rows.len(), 2);
1272 assert_eq!(rows[0].pass_rate, Some(0.9));
1273 assert_eq!(rows[1].pass_rate, Some(0.4));
1274
1275 let rows = find(FindQuery {
1277 pkg: Some(pkg.clone()),
1278 min_pass_rate: Some(0.8),
1279 sort: Some("pass_rate".into()),
1280 ..Default::default()
1281 })
1282 .unwrap();
1283 assert_eq!(rows.len(), 2);
1284 assert!(rows.iter().all(|r| r.pass_rate.unwrap() >= 0.8));
1285
1286 let rows = find(FindQuery {
1288 pkg: Some(pkg.clone()),
1289 sort: Some("pass_rate".into()),
1290 limit: Some(1),
1291 ..Default::default()
1292 })
1293 .unwrap();
1294 assert_eq!(rows.len(), 1);
1295 assert_eq!(rows[0].pass_rate, Some(1.0));
1296
1297 cleanup(&pkg);
1298 }
1299
1300 #[test]
1303 fn write_and_read_samples_roundtrip() {
1304 let pkg = unique_pkg();
1305 let (id, _) = create(json!({
1306 "pkg": { "name": pkg },
1307 "stats": { "pass_rate": 0.5 }
1308 }))
1309 .unwrap();
1310
1311 let samples = vec![
1312 json!({ "case": "c0", "passed": true, "score": 1.0 }),
1313 json!({ "case": "c1", "passed": false, "score": 0.0 }),
1314 json!({ "case": "c2", "passed": true, "score": 0.75 }),
1315 ];
1316 let path = write_samples(&id, samples.clone()).unwrap();
1317 assert!(path.exists());
1318 assert!(path.to_string_lossy().ends_with(".samples.jsonl"));
1319
1320 let got = read_samples(&id, 0, None).unwrap();
1321 assert_eq!(got.len(), 3);
1322 assert_eq!(got[0]["case"], json!("c0"));
1323 assert_eq!(got[2]["score"], json!(0.75));
1324
1325 let slice = read_samples(&id, 1, Some(1)).unwrap();
1327 assert_eq!(slice.len(), 1);
1328 assert_eq!(slice[0]["case"], json!("c1"));
1329
1330 cleanup(&pkg);
1331 }
1332
1333 #[test]
1334 fn write_samples_is_write_once() {
1335 let pkg = unique_pkg();
1336 let (id, _) = create(json!({ "pkg": { "name": pkg } })).unwrap();
1337 write_samples(&id, vec![json!({ "x": 1 })]).unwrap();
1338 let err = write_samples(&id, vec![json!({ "x": 2 })]).unwrap_err();
1339 assert!(err.contains("already exist"), "got: {err}");
1340 cleanup(&pkg);
1341 }
1342
1343 #[test]
1344 fn read_samples_empty_when_absent() {
1345 let pkg = unique_pkg();
1346 let (id, _) = create(json!({ "pkg": { "name": pkg } })).unwrap();
1347 let got = read_samples(&id, 0, None).unwrap();
1348 assert!(got.is_empty());
1349 cleanup(&pkg);
1350 }
1351
1352 #[test]
1353 fn get_by_alias_roundtrip() {
1354 let pkg = unique_pkg();
1355 let (id, _) = create(json!({
1356 "pkg": { "name": pkg },
1357 "stats": { "pass_rate": 0.85 }
1358 }))
1359 .unwrap();
1360
1361 let alias_name = format!("best_{pkg}");
1362 alias_set(&alias_name, &id, Some(&pkg), None).unwrap();
1363
1364 let card = get_by_alias(&alias_name).unwrap().unwrap();
1365 assert_eq!(card["card_id"], json!(id));
1366 assert_eq!(card["stats"]["pass_rate"], json!(0.85));
1367
1368 assert!(get_by_alias("nonexistent_alias_xyz").unwrap().is_none());
1369
1370 cleanup(&pkg);
1371 }
1372
1373 #[test]
1374 fn samples_errors_on_missing_card() {
1375 let err = write_samples("does_not_exist_xyz_samples", vec![json!({})]).unwrap_err();
1376 assert!(err.contains("not found"));
1377 }
1378
1379 #[test]
1382 fn import_from_dir_copies_cards() {
1383 let pkg = unique_pkg();
1384 let tmp = tempfile::tempdir().unwrap();
1385
1386 let card_id = format!("{pkg}_imported");
1388 let card_content = format!(
1389 "schema_version = \"{SCHEMA_VERSION}\"\ncard_id = \"{card_id}\"\npkg = \"{pkg}\"\n"
1390 );
1391 fs::write(tmp.path().join(format!("{card_id}.toml")), &card_content).unwrap();
1392
1393 fs::write(
1395 tmp.path().join(format!("{card_id}.samples.jsonl")),
1396 "{\"case\":\"c0\"}\n",
1397 )
1398 .unwrap();
1399
1400 let (imported, skipped) = import_from_dir(tmp.path(), &pkg).unwrap();
1401 assert_eq!(imported, vec![card_id.clone()]);
1402 assert!(skipped.is_empty());
1403
1404 let got = get(&card_id).unwrap().unwrap();
1406 assert_eq!(got["card_id"], json!(card_id));
1407
1408 let samples = read_samples(&card_id, 0, None).unwrap();
1410 assert_eq!(samples.len(), 1);
1411
1412 cleanup(&pkg);
1413 }
1414
1415 #[test]
1416 fn import_from_dir_skips_existing() {
1417 let pkg = unique_pkg();
1418 let (existing_id, _) = create(json!({
1420 "pkg": { "name": pkg },
1421 "stats": { "pass_rate": 0.5 }
1422 }))
1423 .unwrap();
1424
1425 let tmp = tempfile::tempdir().unwrap();
1427 let card_content = format!(
1428 "schema_version = \"{SCHEMA_VERSION}\"\ncard_id = \"{existing_id}\"\npkg = \"{pkg}\"\n"
1429 );
1430 fs::write(
1431 tmp.path().join(format!("{existing_id}.toml")),
1432 &card_content,
1433 )
1434 .unwrap();
1435
1436 let (imported, skipped) = import_from_dir(tmp.path(), &pkg).unwrap();
1437 assert!(imported.is_empty());
1438 assert_eq!(skipped, vec![existing_id.clone()]);
1439
1440 let got = get(&existing_id).unwrap().unwrap();
1442 assert_eq!(got["stats"]["pass_rate"], json!(0.5));
1443
1444 cleanup(&pkg);
1445 }
1446
1447 #[test]
1448 fn import_from_dir_skips_non_card_toml() {
1449 let pkg = unique_pkg();
1450 let tmp = tempfile::tempdir().unwrap();
1451
1452 fs::write(tmp.path().join("not_a_card.toml"), "title = \"hello\"\n").unwrap();
1454
1455 let (imported, skipped) = import_from_dir(tmp.path(), &pkg).unwrap();
1456 assert!(imported.is_empty());
1457 assert!(skipped.is_empty());
1458
1459 cleanup(&pkg);
1460 }
1461}