1use std::fs;
87use std::path::PathBuf;
88
89use serde_json::{json, Value as Json};
90
91pub const SCHEMA_VERSION: &str = "card/v0";
92
93fn cards_dir() -> Result<PathBuf, String> {
95 let home = dirs::home_dir().ok_or("Cannot determine home directory")?;
96 let dir = home.join(".algocline").join("cards");
97 if !dir.exists() {
98 fs::create_dir_all(&dir).map_err(|e| format!("Failed to create cards dir: {e}"))?;
99 }
100 Ok(dir)
101}
102
103fn pkg_dir(pkg: &str) -> Result<PathBuf, String> {
105 validate_name(pkg, "pkg")?;
106 let dir = cards_dir()?.join(pkg);
107 if !dir.exists() {
108 fs::create_dir_all(&dir).map_err(|e| format!("Failed to create pkg dir: {e}"))?;
109 }
110 Ok(dir)
111}
112
113fn validate_name(name: &str, kind: &str) -> Result<(), String> {
114 if name.is_empty()
115 || name.contains('/')
116 || name.contains('\\')
117 || name.contains("..")
118 || name.contains('\0')
119 {
120 return Err(format!("Invalid {kind} name: '{name}'"));
121 }
122 Ok(())
123}
124
125fn djb2_hex(s: &str) -> String {
127 let mut h: u64 = 5381;
128 for b in s.bytes() {
129 h = h.wrapping_mul(33).wrapping_add(b as u64);
130 }
131 format!("{h:016x}")
132}
133
134fn hash6(s: &str) -> String {
141 let hex = djb2_hex(s);
142 let start = hex.len().saturating_sub(6);
143 hex[start..].to_string()
144}
145
146fn stable_json(v: &Json) -> String {
148 let mut buf = String::new();
149 stable_json_into(v, &mut buf);
150 buf
151}
152fn stable_json_into(v: &Json, buf: &mut String) {
153 match v {
154 Json::Null => buf.push_str("null"),
155 Json::Bool(b) => buf.push_str(if *b { "true" } else { "false" }),
156 Json::Number(n) => buf.push_str(&n.to_string()),
157 Json::String(s) => {
158 buf.push('"');
159 buf.push_str(s);
160 buf.push('"');
161 }
162 Json::Array(a) => {
163 buf.push('[');
164 for (i, item) in a.iter().enumerate() {
165 if i > 0 {
166 buf.push(',');
167 }
168 stable_json_into(item, buf);
169 }
170 buf.push(']');
171 }
172 Json::Object(m) => {
173 let mut keys: Vec<&String> = m.keys().collect();
174 keys.sort();
175 buf.push('{');
176 for (i, k) in keys.iter().enumerate() {
177 if i > 0 {
178 buf.push(',');
179 }
180 buf.push('"');
181 buf.push_str(k);
182 buf.push_str("\":");
183 stable_json_into(&m[*k], buf);
184 }
185 buf.push('}');
186 }
187 }
188}
189
190fn short_model(id: &str) -> String {
193 if id.is_empty() {
194 return "model".into();
195 }
196 let stripped = id
198 .strip_prefix("claude-")
199 .or_else(|| id.strip_prefix("gpt-"))
200 .unwrap_or(id);
201 let s: String = stripped
203 .chars()
204 .filter(|c| c.is_ascii_alphanumeric())
205 .collect();
206 if s.is_empty() {
207 "model".into()
208 } else {
209 s
210 }
211}
212
213fn now_rfc3339() -> String {
215 let secs = std::time::SystemTime::now()
216 .duration_since(std::time::UNIX_EPOCH)
217 .map(|d| d.as_secs())
218 .unwrap_or(0) as i64;
219 let days = secs.div_euclid(86400);
220 let tod = secs.rem_euclid(86400);
221 let (y, mo, d) = civil_from_days(days);
222 let hh = tod / 3600;
223 let mm = (tod % 3600) / 60;
224 let ss = tod % 60;
225 format!("{y:04}-{mo:02}-{d:02}T{hh:02}:{mm:02}:{ss:02}Z")
226}
227
228fn now_compact() -> String {
234 let secs = std::time::SystemTime::now()
235 .duration_since(std::time::UNIX_EPOCH)
236 .map(|d| d.as_secs())
237 .unwrap_or(0) as i64;
238 let days = secs.div_euclid(86400);
239 let tod = secs.rem_euclid(86400);
240 let (y, mo, d) = civil_from_days(days);
241 let hh = tod / 3600;
242 let mm = (tod % 3600) / 60;
243 let ss = tod % 60;
244 format!("{y:04}{mo:02}{d:02}T{hh:02}{mm:02}{ss:02}")
245}
246
247fn civil_from_days(z: i64) -> (i32, u32, u32) {
249 let z = z + 719468;
250 let era = if z >= 0 { z } else { z - 146096 } / 146097;
251 let doe = (z - era * 146097) as u64;
252 let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365;
253 let y = yoe as i64 + era * 400;
254 let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
255 let mp = (5 * doy + 2) / 153;
256 let d = (doy - (153 * mp + 2) / 5 + 1) as u32;
257 let m = (if mp < 10 { mp + 3 } else { mp - 9 }) as u32;
258 let y = y + if m <= 2 { 1 } else { 0 };
259 (y as i32, m, d)
260}
261
262fn json_to_toml(v: Json) -> Result<toml::Value, String> {
265 Ok(match v {
266 Json::Null => return Err("TOML does not support null values".into()),
267 Json::Bool(b) => toml::Value::Boolean(b),
268 Json::Number(n) => {
269 if let Some(i) = n.as_i64() {
270 toml::Value::Integer(i)
271 } else if let Some(f) = n.as_f64() {
272 toml::Value::Float(f)
273 } else {
274 return Err(format!("Unsupported number: {n}"));
275 }
276 }
277 Json::String(s) => toml::Value::String(s),
278 Json::Array(a) => {
279 let mut out = Vec::with_capacity(a.len());
280 for item in a {
281 if !item.is_null() {
282 out.push(json_to_toml(item)?);
283 }
284 }
285 toml::Value::Array(out)
286 }
287 Json::Object(m) => {
288 let mut table = toml::map::Map::new();
289 for (k, val) in m {
290 if val.is_null() {
291 continue;
292 }
293 table.insert(k, json_to_toml(val)?);
294 }
295 toml::Value::Table(table)
296 }
297 })
298}
299
300fn toml_to_json(v: toml::Value) -> Json {
302 match v {
303 toml::Value::String(s) => Json::String(s),
304 toml::Value::Integer(i) => json!(i),
305 toml::Value::Float(f) => json!(f),
306 toml::Value::Boolean(b) => Json::Bool(b),
307 toml::Value::Datetime(dt) => Json::String(dt.to_string()),
308 toml::Value::Array(a) => Json::Array(a.into_iter().map(toml_to_json).collect()),
309 toml::Value::Table(t) => {
310 let mut m = serde_json::Map::new();
311 for (k, v) in t {
312 m.insert(k, toml_to_json(v));
313 }
314 Json::Object(m)
315 }
316 }
317}
318
319fn require_pkg_name(input: &Json) -> Result<String, String> {
321 let name = input
322 .get("pkg")
323 .and_then(|p| p.get("name"))
324 .and_then(|n| n.as_str())
325 .ok_or_else(|| "alc.card.create: pkg.name is required".to_string())?
326 .to_string();
327 validate_name(&name, "pkg")?;
328 Ok(name)
329}
330
331pub fn create(mut input: Json) -> Result<(String, PathBuf), String> {
333 if !input.is_object() {
334 return Err("alc.card.create: input must be a table".into());
335 }
336 let pkg_name = require_pkg_name(&input)?;
337 let obj = input.as_object_mut().unwrap();
338
339 obj.entry("schema_version".to_string())
341 .or_insert_with(|| json!(SCHEMA_VERSION));
342 obj.entry("created_at".to_string())
343 .or_insert_with(|| json!(now_rfc3339()));
344 obj.entry("created_by".to_string())
345 .or_insert_with(|| json!(format!("alc@{}", env!("CARGO_PKG_VERSION"))));
346
347 if let Some(params) = obj.get("params").cloned() {
349 if params.is_object() {
350 let fp = djb2_hex(&stable_json(¶ms));
351 obj.insert("param_fingerprint".to_string(), json!(fp));
352 }
353 }
354
355 let card_id = match obj.get("card_id").and_then(|v| v.as_str()) {
357 Some(id) if !id.is_empty() => id.to_string(),
358 _ => {
359 let model_id = obj
360 .get("model")
361 .and_then(|m| m.get("id"))
362 .and_then(|v| v.as_str())
363 .unwrap_or("");
364 let model_short = short_model(model_id);
365 let ts = now_compact();
366 let fp_seed = stable_json(&Json::Object(obj.clone()));
367 let h = hash6(&fp_seed);
368 format!("{pkg_name}_{model_short}_{ts}_{h}")
369 }
370 };
371 validate_name(&card_id, "card_id")?;
372 obj.insert("card_id".to_string(), json!(card_id.clone()));
373
374 let dir = pkg_dir(&pkg_name)?;
376 let path = dir.join(format!("{card_id}.toml"));
377 if path.exists() {
378 return Err(format!(
379 "alc.card.create: card '{card_id}' already exists (immutable)"
380 ));
381 }
382 let toml_val = json_to_toml(input)?;
383 let text = toml::to_string_pretty(&toml_val)
384 .map_err(|e| format!("Failed to serialize card TOML: {e}"))?;
385 let tmp = path.with_extension("toml.tmp");
386 fs::write(&tmp, &text).map_err(|e| format!("Failed to write card tmp: {e}"))?;
387 fs::rename(&tmp, &path).map_err(|e| format!("Failed to rename card file: {e}"))?;
388
389 Ok((card_id, path))
390}
391
392fn find_card_path(card_id: &str) -> Result<Option<PathBuf>, String> {
394 validate_name(card_id, "card_id")?;
395 let root = cards_dir()?;
396 let entries = fs::read_dir(&root).map_err(|e| format!("Failed to read cards dir: {e}"))?;
397 for entry in entries.flatten() {
398 let p = entry.path();
399 if p.is_dir() {
400 let candidate = p.join(format!("{card_id}.toml"));
401 if candidate.exists() {
402 return Ok(Some(candidate));
403 }
404 }
405 }
406 Ok(None)
407}
408
409pub fn get(card_id: &str) -> Result<Option<Json>, String> {
411 let path = match find_card_path(card_id)? {
412 Some(p) => p,
413 None => return Ok(None),
414 };
415 let text =
416 fs::read_to_string(&path).map_err(|e| format!("Failed to read card '{card_id}': {e}"))?;
417 let val: toml::Value =
418 toml::from_str(&text).map_err(|e| format!("Failed to parse card '{card_id}': {e}"))?;
419 Ok(Some(toml_to_json(val)))
420}
421
422#[derive(Debug, Clone)]
424pub struct Summary {
425 pub card_id: String,
426 pub pkg: String,
427 pub created_at: Option<String>,
428 pub model: Option<String>,
429 pub scenario: Option<String>,
430 pub pass_rate: Option<f64>,
431}
432
433impl Summary {
434 fn to_json(&self) -> Json {
435 let mut m = serde_json::Map::new();
436 m.insert("card_id".into(), json!(self.card_id));
437 m.insert("pkg".into(), json!(self.pkg));
438 if let Some(v) = &self.created_at {
439 m.insert("created_at".into(), json!(v));
440 }
441 if let Some(v) = &self.model {
442 m.insert("model".into(), json!(v));
443 }
444 if let Some(v) = &self.scenario {
445 m.insert("scenario".into(), json!(v));
446 }
447 if let Some(v) = self.pass_rate {
448 m.insert("pass_rate".into(), json!(v));
449 }
450 Json::Object(m)
451 }
452}
453
454fn summarize(path: &std::path::Path, pkg: &str) -> Option<Summary> {
455 let text = fs::read_to_string(path).ok()?;
456 let val: toml::Value = toml::from_str(&text).ok()?;
457 let card_id = val
458 .get("card_id")
459 .and_then(|v| v.as_str())
460 .or_else(|| path.file_stem().and_then(|s| s.to_str()))?
461 .to_string();
462 let created_at = val
463 .get("created_at")
464 .and_then(|v| v.as_str())
465 .map(String::from);
466 let model = val
467 .get("model")
468 .and_then(|m| m.get("id"))
469 .and_then(|v| v.as_str())
470 .map(String::from);
471 let scenario = val
472 .get("scenario")
473 .and_then(|s| s.get("name"))
474 .and_then(|v| v.as_str())
475 .map(String::from);
476 let pass_rate = val
477 .get("stats")
478 .and_then(|s| s.get("pass_rate"))
479 .and_then(|v| v.as_float());
480 Some(Summary {
481 card_id,
482 pkg: pkg.to_string(),
483 created_at,
484 model,
485 scenario,
486 pass_rate,
487 })
488}
489
490pub fn list(pkg_filter: Option<&str>) -> Result<Vec<Summary>, String> {
492 let root = cards_dir()?;
493 let mut out = Vec::new();
494
495 let pkg_dirs: Vec<PathBuf> = if let Some(p) = pkg_filter {
496 validate_name(p, "pkg")?;
497 let d = root.join(p);
498 if d.is_dir() {
499 vec![d]
500 } else {
501 vec![]
502 }
503 } else {
504 fs::read_dir(&root)
505 .map_err(|e| format!("Failed to read cards dir: {e}"))?
506 .flatten()
507 .map(|e| e.path())
508 .filter(|p| p.is_dir())
509 .collect()
510 };
511
512 for pdir in pkg_dirs {
513 let pkg = pdir
514 .file_name()
515 .and_then(|s| s.to_str())
516 .unwrap_or("")
517 .to_string();
518 let entries = match fs::read_dir(&pdir) {
519 Ok(e) => e,
520 Err(_) => continue,
521 };
522 for entry in entries.flatten() {
523 let p = entry.path();
524 if p.extension().and_then(|s| s.to_str()) != Some("toml") {
525 continue;
526 }
527 if let Some(s) = summarize(&p, &pkg) {
528 out.push(s);
529 }
530 }
531 }
532
533 out.sort_by(|a, b| {
537 b.created_at
538 .cmp(&a.created_at)
539 .then_with(|| b.card_id.cmp(&a.card_id))
540 });
541 Ok(out)
542}
543
544pub fn summaries_to_json(rows: &[Summary]) -> Json {
545 Json::Array(rows.iter().map(|s| s.to_json()).collect())
546}
547
548pub fn append(card_id: &str, fields: Json) -> Result<Json, String> {
561 let path = find_card_path(card_id)?
562 .ok_or_else(|| format!("alc.card.append: card '{card_id}' not found"))?;
563 let fields_obj = match fields {
564 Json::Object(m) => m,
565 _ => return Err("alc.card.append: fields must be a table".into()),
566 };
567
568 let text =
569 fs::read_to_string(&path).map_err(|e| format!("Failed to read card '{card_id}': {e}"))?;
570 let existing: toml::Value =
571 toml::from_str(&text).map_err(|e| format!("Failed to parse card '{card_id}': {e}"))?;
572 let mut existing_json = toml_to_json(existing);
573 let existing_obj = existing_json
574 .as_object_mut()
575 .ok_or_else(|| format!("Card '{card_id}' is not a table"))?;
576
577 for (k, v) in fields_obj {
578 if existing_obj.contains_key(&k) {
579 return Err(format!(
580 "alc.card.append: key '{k}' already set on card '{card_id}' (immutable)"
581 ));
582 }
583 if !v.is_null() {
584 existing_obj.insert(k, v);
585 }
586 }
587
588 let toml_val = json_to_toml(existing_json.clone())?;
589 let text = toml::to_string_pretty(&toml_val)
590 .map_err(|e| format!("Failed to serialize card TOML: {e}"))?;
591 let tmp = path.with_extension("toml.tmp");
592 fs::write(&tmp, &text).map_err(|e| format!("Failed to write card tmp: {e}"))?;
593 fs::rename(&tmp, &path).map_err(|e| format!("Failed to rename card file: {e}"))?;
594
595 Ok(existing_json)
596}
597
598fn aliases_path() -> Result<PathBuf, String> {
600 Ok(cards_dir()?.join("_aliases.toml"))
601}
602
603#[derive(Debug, Clone)]
604pub struct Alias {
605 pub name: String,
606 pub card_id: String,
607 pub pkg: Option<String>,
608 pub set_at: String,
609 pub note: Option<String>,
610}
611
612impl Alias {
613 fn to_json(&self) -> Json {
614 let mut m = serde_json::Map::new();
615 m.insert("name".into(), json!(self.name));
616 m.insert("card_id".into(), json!(self.card_id));
617 if let Some(p) = &self.pkg {
618 m.insert("pkg".into(), json!(p));
619 }
620 m.insert("set_at".into(), json!(self.set_at));
621 if let Some(n) = &self.note {
622 m.insert("note".into(), json!(n));
623 }
624 Json::Object(m)
625 }
626}
627
628fn read_aliases() -> Result<Vec<Alias>, String> {
629 let path = aliases_path()?;
630 if !path.exists() {
631 return Ok(Vec::new());
632 }
633 let text =
634 fs::read_to_string(&path).map_err(|e| format!("Failed to read aliases file: {e}"))?;
635 let val: toml::Value =
636 toml::from_str(&text).map_err(|e| format!("Failed to parse aliases file: {e}"))?;
637 let arr = val
638 .get("alias")
639 .and_then(|v| v.as_array())
640 .cloned()
641 .unwrap_or_default();
642 let mut out = Vec::with_capacity(arr.len());
643 for entry in arr {
644 let t = match entry {
645 toml::Value::Table(t) => t,
646 _ => continue,
647 };
648 let name = match t.get("name").and_then(|v| v.as_str()) {
649 Some(s) => s.to_string(),
650 None => continue,
651 };
652 let card_id = match t.get("card_id").and_then(|v| v.as_str()) {
653 Some(s) => s.to_string(),
654 None => continue,
655 };
656 out.push(Alias {
657 name,
658 card_id,
659 pkg: t.get("pkg").and_then(|v| v.as_str()).map(String::from),
660 set_at: t
661 .get("set_at")
662 .and_then(|v| v.as_str())
663 .map(String::from)
664 .unwrap_or_default(),
665 note: t.get("note").and_then(|v| v.as_str()).map(String::from),
666 });
667 }
668 Ok(out)
669}
670
671fn write_aliases(aliases: &[Alias]) -> Result<(), String> {
672 let path = aliases_path()?;
673 let mut arr = Vec::with_capacity(aliases.len());
674 for a in aliases {
675 let mut t = toml::map::Map::new();
676 t.insert("name".into(), toml::Value::String(a.name.clone()));
677 t.insert("card_id".into(), toml::Value::String(a.card_id.clone()));
678 if let Some(p) = &a.pkg {
679 t.insert("pkg".into(), toml::Value::String(p.clone()));
680 }
681 t.insert("set_at".into(), toml::Value::String(a.set_at.clone()));
682 if let Some(n) = &a.note {
683 t.insert("note".into(), toml::Value::String(n.clone()));
684 }
685 arr.push(toml::Value::Table(t));
686 }
687 let mut root = toml::map::Map::new();
688 root.insert("alias".into(), toml::Value::Array(arr));
689 let text = toml::to_string_pretty(&toml::Value::Table(root))
690 .map_err(|e| format!("Failed to serialize aliases: {e}"))?;
691 let tmp = path.with_extension("toml.tmp");
692 fs::write(&tmp, &text).map_err(|e| format!("Failed to write aliases tmp: {e}"))?;
693 fs::rename(&tmp, &path).map_err(|e| format!("Failed to rename aliases file: {e}"))?;
694 Ok(())
695}
696
697pub fn alias_set(
703 name: &str,
704 card_id: &str,
705 pkg: Option<&str>,
706 note: Option<&str>,
707) -> Result<Alias, String> {
708 validate_name(name, "alias")?;
709 if find_card_path(card_id)?.is_none() {
710 return Err(format!("alc.card.alias_set: card '{card_id}' not found"));
711 }
712 let mut aliases = read_aliases()?;
713 aliases.retain(|a| a.name != name);
714 let entry = Alias {
715 name: name.to_string(),
716 card_id: card_id.to_string(),
717 pkg: pkg.map(String::from),
718 set_at: now_rfc3339(),
719 note: note.map(String::from),
720 };
721 aliases.push(entry.clone());
722 write_aliases(&aliases)?;
723 Ok(entry)
724}
725
726pub fn get_by_alias(name: &str) -> Result<Option<Json>, String> {
732 validate_name(name, "alias")?;
733 let aliases = read_aliases()?;
734 let Some(alias) = aliases.into_iter().find(|a| a.name == name) else {
735 return Ok(None);
736 };
737 match get(&alias.card_id)? {
738 Some(card) => Ok(Some(card)),
739 None => Err(format!(
740 "alc.card.get_by_alias: alias '{name}' points at missing card '{}'",
741 alias.card_id
742 )),
743 }
744}
745
746pub fn alias_list(pkg_filter: Option<&str>) -> Result<Vec<Alias>, String> {
748 let mut aliases = read_aliases()?;
749 if let Some(p) = pkg_filter {
750 aliases.retain(|a| a.pkg.as_deref() == Some(p));
751 }
752 Ok(aliases)
753}
754
755pub fn aliases_to_json(rows: &[Alias]) -> Json {
756 Json::Array(rows.iter().map(|a| a.to_json()).collect())
757}
758
759#[derive(Debug, Clone, PartialEq)]
795pub enum CmpOp {
796 Eq,
797 Ne,
798 Lt,
799 Lte,
800 Gt,
801 Gte,
802 In,
803 Nin,
804 Exists,
805 Contains,
806 StartsWith,
807}
808
809impl CmpOp {
810 fn from_key(k: &str) -> Option<Self> {
811 Some(match k {
812 "eq" => Self::Eq,
813 "ne" => Self::Ne,
814 "lt" => Self::Lt,
815 "lte" => Self::Lte,
816 "gt" => Self::Gt,
817 "gte" => Self::Gte,
818 "in" => Self::In,
819 "nin" => Self::Nin,
820 "exists" => Self::Exists,
821 "contains" => Self::Contains,
822 "starts_with" => Self::StartsWith,
823 _ => return None,
824 })
825 }
826}
827
828#[derive(Debug, Clone)]
831pub struct Comparison {
832 pub path: Vec<String>,
833 pub op: CmpOp,
834 pub value: Json,
835}
836
837#[derive(Debug, Clone)]
839pub enum Predicate {
840 And(Vec<Predicate>),
841 Or(Vec<Predicate>),
842 Not(Box<Predicate>),
843 Cmp(Comparison),
844}
845
846fn is_operator_object(obj: &serde_json::Map<String, Json>) -> bool {
849 if obj.is_empty() {
850 return false;
851 }
852 obj.keys().all(|k| CmpOp::from_key(k).is_some())
853}
854
855pub fn parse_where(value: &Json) -> Result<Predicate, String> {
860 parse_predicate(value, &[])
861}
862
863fn parse_predicate(value: &Json, prefix: &[String]) -> Result<Predicate, String> {
864 let obj = value
865 .as_object()
866 .ok_or_else(|| "where clause must be a table".to_string())?;
867
868 let mut clauses: Vec<Predicate> = Vec::new();
869
870 for (key, val) in obj {
871 match key.as_str() {
872 "_and" => {
873 let arr = val
874 .as_array()
875 .ok_or_else(|| "_and must be an array of sub-predicates".to_string())?;
876 let mut subs = Vec::with_capacity(arr.len());
877 for sub in arr {
878 subs.push(parse_predicate(sub, prefix)?);
879 }
880 clauses.push(Predicate::And(subs));
881 }
882 "_or" => {
883 let arr = val
884 .as_array()
885 .ok_or_else(|| "_or must be an array of sub-predicates".to_string())?;
886 let mut subs = Vec::with_capacity(arr.len());
887 for sub in arr {
888 subs.push(parse_predicate(sub, prefix)?);
889 }
890 clauses.push(Predicate::Or(subs));
891 }
892 "_not" => {
893 clauses.push(Predicate::Not(Box::new(parse_predicate(val, prefix)?)));
894 }
895 _ => {
896 let mut new_path = prefix.to_vec();
898 new_path.push(key.clone());
899
900 match val {
901 Json::Object(m) if is_operator_object(m) => {
902 for (op_key, op_val) in m {
904 let op = CmpOp::from_key(op_key).expect("validated above");
905 clauses.push(Predicate::Cmp(Comparison {
906 path: new_path.clone(),
907 op,
908 value: op_val.clone(),
909 }));
910 }
911 }
912 Json::Object(_) => {
913 clauses.push(parse_predicate(val, &new_path)?);
915 }
916 _ => {
917 clauses.push(Predicate::Cmp(Comparison {
919 path: new_path,
920 op: CmpOp::Eq,
921 value: val.clone(),
922 }));
923 }
924 }
925 }
926 }
927 }
928
929 if clauses.len() == 1 {
930 Ok(clauses.remove(0))
931 } else {
932 Ok(Predicate::And(clauses))
933 }
934}
935
936fn fetch_path<'a>(card: &'a Json, path: &[String]) -> Option<&'a Json> {
938 let mut node = card;
939 for key in path {
940 let obj = node.as_object()?;
941 node = obj.get(key)?;
942 }
943 Some(node)
944}
945
946fn json_cmp(a: &Json, b: &Json) -> Option<std::cmp::Ordering> {
949 match (a, b) {
950 (Json::Number(x), Json::Number(y)) => {
951 let xf = x.as_f64()?;
952 let yf = y.as_f64()?;
953 xf.partial_cmp(&yf)
954 }
955 (Json::String(x), Json::String(y)) => Some(x.cmp(y)),
956 (Json::Bool(x), Json::Bool(y)) => Some(x.cmp(y)),
957 _ => None,
958 }
959}
960
961fn json_eq(a: &Json, b: &Json) -> bool {
962 match (a, b) {
963 (Json::Number(x), Json::Number(y)) => match (x.as_f64(), y.as_f64()) {
964 (Some(xf), Some(yf)) => xf == yf,
965 _ => a == b,
966 },
967 _ => a == b,
968 }
969}
970
971fn eval_cmp(cmp: &Comparison, card: &Json) -> bool {
972 let actual = fetch_path(card, &cmp.path);
973 let exists = actual.is_some();
974
975 match cmp.op {
976 CmpOp::Exists => {
977 let want = cmp.value.as_bool().unwrap_or(true);
978 exists == want
979 }
980 CmpOp::Ne => match actual {
981 None => true,
982 Some(v) => !json_eq(v, &cmp.value),
983 },
984 CmpOp::Nin => match actual {
985 None => true,
986 Some(v) => match cmp.value.as_array() {
987 Some(arr) => !arr.iter().any(|e| json_eq(e, v)),
988 None => false,
989 },
990 },
991 CmpOp::Eq => actual.is_some_and(|v| json_eq(v, &cmp.value)),
992 CmpOp::In => actual.is_some_and(|v| match cmp.value.as_array() {
993 Some(arr) => arr.iter().any(|e| json_eq(e, v)),
994 None => false,
995 }),
996 CmpOp::Lt | CmpOp::Lte | CmpOp::Gt | CmpOp::Gte => {
997 let Some(v) = actual else { return false };
998 let Some(ord) = json_cmp(v, &cmp.value) else {
999 return false;
1000 };
1001 use std::cmp::Ordering::{Equal, Greater, Less};
1002 matches!(
1003 (&cmp.op, ord),
1004 (CmpOp::Lt, Less)
1005 | (CmpOp::Lte, Less | Equal)
1006 | (CmpOp::Gt, Greater)
1007 | (CmpOp::Gte, Greater | Equal)
1008 )
1009 }
1010 CmpOp::Contains => {
1011 let Some(Json::String(haystack)) = actual else {
1012 return false;
1013 };
1014 let Some(needle) = cmp.value.as_str() else {
1015 return false;
1016 };
1017 haystack.contains(needle)
1018 }
1019 CmpOp::StartsWith => {
1020 let Some(Json::String(haystack)) = actual else {
1021 return false;
1022 };
1023 let Some(needle) = cmp.value.as_str() else {
1024 return false;
1025 };
1026 haystack.starts_with(needle)
1027 }
1028 }
1029}
1030
1031pub fn eval_predicate(pred: &Predicate, card: &Json) -> bool {
1033 match pred {
1034 Predicate::And(subs) => subs.iter().all(|p| eval_predicate(p, card)),
1035 Predicate::Or(subs) => subs.iter().any(|p| eval_predicate(p, card)),
1036 Predicate::Not(sub) => !eval_predicate(sub, card),
1037 Predicate::Cmp(c) => eval_cmp(c, card),
1038 }
1039}
1040
1041#[derive(Debug, Clone)]
1047pub struct OrderKey {
1048 pub path: Vec<String>,
1049 pub desc: bool,
1050}
1051
1052impl OrderKey {
1053 fn parse(raw: &str) -> Result<Self, String> {
1054 if raw.is_empty() {
1055 return Err("order_by key must not be empty".into());
1056 }
1057 let (desc, rest) = if let Some(r) = raw.strip_prefix('-') {
1058 (true, r)
1059 } else {
1060 (false, raw)
1061 };
1062 let path: Vec<String> = rest.split('.').map(|s| s.to_string()).collect();
1063 if path.iter().any(|p| p.is_empty()) {
1064 return Err(format!("invalid order_by key: '{raw}'"));
1065 }
1066 Ok(Self { path, desc })
1067 }
1068}
1069
1070pub fn parse_order_by(value: &Json) -> Result<Vec<OrderKey>, String> {
1074 match value {
1075 Json::String(s) => Ok(vec![OrderKey::parse(s)?]),
1076 Json::Array(arr) => {
1077 let mut out = Vec::with_capacity(arr.len());
1078 for v in arr {
1079 let s = v
1080 .as_str()
1081 .ok_or_else(|| "order_by array must contain strings".to_string())?;
1082 out.push(OrderKey::parse(s)?);
1083 }
1084 Ok(out)
1085 }
1086 _ => Err("order_by must be a string or array of strings".into()),
1087 }
1088}
1089
1090#[derive(Debug, Default, Clone)]
1092pub struct FindQuery {
1093 pub pkg: Option<String>,
1095 pub where_: Option<Predicate>,
1097 pub order_by: Vec<OrderKey>,
1099 pub limit: Option<usize>,
1100 pub offset: Option<usize>,
1101}
1102
1103#[derive(Debug, Clone)]
1109struct CardRow {
1110 full: Json,
1111 summary: Summary,
1112}
1113
1114fn load_full(path: &std::path::Path, pkg: &str) -> Option<CardRow> {
1116 let text = fs::read_to_string(path).ok()?;
1117 let val: toml::Value = toml::from_str(&text).ok()?;
1118 let json = toml_to_json(val);
1119
1120 let card_id = json
1121 .get("card_id")
1122 .and_then(|v| v.as_str())
1123 .or_else(|| path.file_stem().and_then(|s| s.to_str()))?
1124 .to_string();
1125 let created_at = json
1126 .get("created_at")
1127 .and_then(|v| v.as_str())
1128 .map(String::from);
1129 let model = json
1130 .get("model")
1131 .and_then(|m| m.get("id"))
1132 .and_then(|v| v.as_str())
1133 .map(String::from);
1134 let scenario = json
1135 .get("scenario")
1136 .and_then(|s| s.get("name"))
1137 .and_then(|v| v.as_str())
1138 .map(String::from);
1139 let pass_rate = json
1140 .get("stats")
1141 .and_then(|s| s.get("pass_rate"))
1142 .and_then(|v| v.as_f64());
1143
1144 Some(CardRow {
1145 full: json,
1146 summary: Summary {
1147 card_id,
1148 pkg: pkg.to_string(),
1149 created_at,
1150 model,
1151 scenario,
1152 pass_rate,
1153 },
1154 })
1155}
1156
1157fn order_cards(a: &CardRow, b: &CardRow, keys: &[OrderKey]) -> std::cmp::Ordering {
1159 use std::cmp::Ordering;
1160 for k in keys {
1161 let va = fetch_path(&a.full, &k.path);
1162 let vb = fetch_path(&b.full, &k.path);
1163 let ord = match (va, vb) {
1164 (None, None) => Ordering::Equal,
1165 (None, Some(_)) => Ordering::Greater, (Some(_), None) => Ordering::Less,
1167 (Some(x), Some(y)) => json_cmp(x, y).unwrap_or(Ordering::Equal),
1168 };
1169 let ord = if k.desc { ord.reverse() } else { ord };
1170 if ord != Ordering::Equal {
1171 return ord;
1172 }
1173 }
1174 Ordering::Equal
1175}
1176
1177const SUMMARY_SORT_FIELDS: &[&str] = &[
1179 "card_id",
1180 "created_at",
1181 "stats.pass_rate",
1182 "scenario.name",
1183 "model.id",
1184];
1185
1186fn is_lightweight_query(q: &FindQuery) -> bool {
1189 q.where_.is_none()
1190 && q.order_by
1191 .iter()
1192 .all(|k| SUMMARY_SORT_FIELDS.contains(&k.path.join(".").as_str()))
1193}
1194
1195fn order_summaries(a: &Summary, b: &Summary, keys: &[OrderKey]) -> std::cmp::Ordering {
1197 use std::cmp::Ordering;
1198 for k in keys {
1199 let key_str = k.path.join(".");
1200 let ord = match key_str.as_str() {
1201 "card_id" => a.card_id.cmp(&b.card_id),
1202 "created_at" => a.created_at.cmp(&b.created_at),
1203 "stats.pass_rate" => match (a.pass_rate, b.pass_rate) {
1204 (None, None) => Ordering::Equal,
1205 (None, Some(_)) => Ordering::Greater,
1206 (Some(_), None) => Ordering::Less,
1207 (Some(x), Some(y)) => x.partial_cmp(&y).unwrap_or(Ordering::Equal),
1208 },
1209 "scenario.name" => a.scenario.cmp(&b.scenario),
1210 "model.id" => a.model.cmp(&b.model),
1211 _ => Ordering::Equal,
1212 };
1213 let ord = if k.desc { ord.reverse() } else { ord };
1214 if ord != Ordering::Equal {
1215 return ord;
1216 }
1217 }
1218 Ordering::Equal
1219}
1220
1221pub fn find(q: FindQuery) -> Result<Vec<Summary>, String> {
1227 if is_lightweight_query(&q) {
1229 let mut rows = list(q.pkg.as_deref())?;
1230 if q.order_by.is_empty() {
1231 rows.sort_by(|a, b| {
1232 b.created_at
1233 .cmp(&a.created_at)
1234 .then_with(|| b.card_id.cmp(&a.card_id))
1235 });
1236 } else {
1237 rows.sort_by(|a, b| order_summaries(a, b, &q.order_by));
1238 }
1239 let out: Vec<Summary> = rows
1240 .into_iter()
1241 .skip(q.offset.unwrap_or(0))
1242 .take(q.limit.unwrap_or(usize::MAX))
1243 .collect();
1244 return Ok(out);
1245 }
1246
1247 let root = cards_dir()?;
1249 let pkg_dirs: Vec<PathBuf> = if let Some(p) = q.pkg.as_deref() {
1250 validate_name(p, "pkg")?;
1251 let d = root.join(p);
1252 if d.is_dir() {
1253 vec![d]
1254 } else {
1255 return Ok(Vec::new());
1256 }
1257 } else {
1258 fs::read_dir(&root)
1259 .map_err(|e| format!("Failed to read cards dir: {e}"))?
1260 .flatten()
1261 .map(|e| e.path())
1262 .filter(|p| p.is_dir())
1263 .collect()
1264 };
1265
1266 let all_rows = scan_pkg_dirs(&pkg_dirs)?;
1267
1268 let mut rows: Vec<CardRow> = if let Some(pred) = &q.where_ {
1270 all_rows
1271 .into_iter()
1272 .filter(|row| eval_predicate(pred, &row.full))
1273 .collect()
1274 } else {
1275 all_rows
1276 };
1277
1278 if q.order_by.is_empty() {
1280 rows.sort_by(|a, b| {
1281 b.summary
1282 .created_at
1283 .cmp(&a.summary.created_at)
1284 .then_with(|| b.summary.card_id.cmp(&a.summary.card_id))
1285 });
1286 } else {
1287 rows.sort_by(|a, b| order_cards(a, b, &q.order_by));
1288 }
1289
1290 let out: Vec<Summary> = rows
1292 .into_iter()
1293 .skip(q.offset.unwrap_or(0))
1294 .take(q.limit.unwrap_or(usize::MAX))
1295 .map(|r| r.summary)
1296 .collect();
1297
1298 Ok(out)
1299}
1300
1301#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
1317pub enum LineageDirection {
1318 #[default]
1319 Up,
1320 Down,
1321 Both,
1322}
1323
1324impl LineageDirection {
1325 pub fn parse(s: &str) -> Result<Self, String> {
1326 match s {
1327 "up" => Ok(Self::Up),
1328 "down" => Ok(Self::Down),
1329 "both" => Ok(Self::Both),
1330 other => Err(format!(
1331 "direction must be 'up', 'down', or 'both' (got '{other}')"
1332 )),
1333 }
1334 }
1335}
1336
1337#[derive(Debug, Clone, Default)]
1339pub struct LineageQuery {
1340 pub card_id: String,
1341 pub direction: LineageDirection,
1342 pub depth: Option<usize>,
1344 pub include_stats: bool,
1346 pub relation_filter: Option<Vec<String>>,
1349}
1350
1351#[derive(Debug, Clone)]
1356pub struct LineageNode {
1357 pub card_id: String,
1358 pub pkg: String,
1359 pub prior_card_id: Option<String>,
1360 pub prior_relation: Option<String>,
1361 pub depth: i32,
1362 pub stats: Option<Json>,
1363}
1364
1365#[derive(Debug, Clone)]
1367pub struct LineageEdge {
1368 pub from: String,
1369 pub to: String,
1370 pub relation: Option<String>,
1371}
1372
1373#[derive(Debug, Clone)]
1375pub struct LineageResult {
1376 pub root: String,
1377 pub nodes: Vec<LineageNode>,
1378 pub edges: Vec<LineageEdge>,
1379 pub truncated: bool,
1380}
1381
1382const DEFAULT_LINEAGE_DEPTH: usize = 10;
1383
1384fn lineage_fields(card: &Json) -> (Option<String>, Option<String>) {
1387 let meta = card.get("metadata");
1388 let prior_card_id = meta
1389 .and_then(|m| m.get("prior_card_id"))
1390 .and_then(|v| v.as_str())
1391 .map(String::from);
1392 let prior_relation = meta
1393 .and_then(|m| m.get("prior_relation"))
1394 .and_then(|v| v.as_str())
1395 .map(String::from);
1396 (prior_card_id, prior_relation)
1397}
1398
1399fn make_node(row: &CardRow, depth: i32, include_stats: bool) -> LineageNode {
1401 let (prior_card_id, prior_relation) = lineage_fields(&row.full);
1402 let stats = if include_stats {
1403 row.full.get("stats").cloned()
1404 } else {
1405 None
1406 };
1407 LineageNode {
1408 card_id: row.summary.card_id.clone(),
1409 pkg: row.summary.pkg.clone(),
1410 prior_card_id,
1411 prior_relation,
1412 depth,
1413 stats,
1414 }
1415}
1416
1417fn relation_passes(filter: &Option<Vec<String>>, relation: &Option<String>) -> bool {
1420 match filter {
1421 None => true,
1422 Some(allowed) => match relation {
1423 Some(r) => allowed.iter().any(|a| a == r),
1424 None => false,
1425 },
1426 }
1427}
1428
1429struct CardIndex {
1431 cards: std::collections::HashMap<String, CardRow>,
1433 children: std::collections::HashMap<String, Vec<String>>,
1435}
1436
1437fn load_card_index() -> Result<CardIndex, String> {
1441 let root = cards_dir()?;
1442 let rows = scan_all_cards(&root)?;
1443
1444 let mut cards = std::collections::HashMap::with_capacity(rows.len());
1445 let mut children: std::collections::HashMap<String, Vec<String>> =
1446 std::collections::HashMap::new();
1447
1448 for row in rows {
1449 let id = row.summary.card_id.clone();
1450 let (prior_id, _) = lineage_fields(&row.full);
1451 if let Some(parent) = prior_id {
1452 children.entry(parent).or_default().push(id.clone());
1453 }
1454 cards.insert(id, row);
1455 }
1456 Ok(CardIndex { cards, children })
1457}
1458
1459fn scan_all_cards(root: &std::path::Path) -> Result<Vec<CardRow>, String> {
1462 let pkg_dirs: Vec<PathBuf> = fs::read_dir(root)
1463 .map_err(|e| format!("Failed to read cards dir: {e}"))?
1464 .flatten()
1465 .map(|e| e.path())
1466 .filter(|p| p.is_dir())
1467 .collect();
1468 scan_pkg_dirs(&pkg_dirs)
1469}
1470
1471fn scan_pkg_dirs(pkg_dirs: &[PathBuf]) -> Result<Vec<CardRow>, String> {
1473 let mut rows = Vec::new();
1474 for pdir in pkg_dirs {
1475 let pkg = pdir
1476 .file_name()
1477 .and_then(|s| s.to_str())
1478 .unwrap_or("")
1479 .to_string();
1480 let entries = match fs::read_dir(pdir) {
1481 Ok(e) => e,
1482 Err(_) => continue,
1483 };
1484 for entry in entries.flatten() {
1485 let p = entry.path();
1486 if p.extension().and_then(|s| s.to_str()) != Some("toml") {
1487 continue;
1488 }
1489 if let Some(row) = load_full(&p, &pkg) {
1490 rows.push(row);
1491 }
1492 }
1493 }
1494 Ok(rows)
1495}
1496
1497struct LineageCtx<'a> {
1499 index: &'a CardIndex,
1500 relation_filter: &'a Option<Vec<String>>,
1501 include_stats: bool,
1502 max_depth: usize,
1503}
1504
1505struct LineageAccum {
1507 nodes: Vec<LineageNode>,
1508 edges: Vec<LineageEdge>,
1509 visited: std::collections::HashSet<String>,
1510 truncated: bool,
1511}
1512
1513fn walk_up(start_id: &str, ctx: &LineageCtx<'_>, acc: &mut LineageAccum) {
1515 let mut cur = start_id.to_string();
1516 for step in 1..=ctx.max_depth {
1517 let Some(row) = ctx.index.cards.get(&cur) else {
1518 return;
1519 };
1520 let (prior_id, prior_rel) = lineage_fields(&row.full);
1521 let Some(prior_id) = prior_id else {
1522 return;
1523 };
1524 if !relation_passes(ctx.relation_filter, &prior_rel) {
1525 return;
1526 }
1527 if acc.visited.contains(&prior_id) {
1528 return;
1529 }
1530 let Some(parent) = ctx.index.cards.get(&prior_id) else {
1531 return;
1532 };
1533 acc.nodes
1534 .push(make_node(parent, -(step as i32), ctx.include_stats));
1535 acc.edges.push(LineageEdge {
1536 from: row.summary.card_id.clone(),
1537 to: parent.summary.card_id.clone(),
1538 relation: prior_rel,
1539 });
1540 acc.visited.insert(prior_id.clone());
1541 cur = prior_id;
1542 }
1543 if let Some(row) = ctx.index.cards.get(&cur) {
1545 let (prior_id, _) = lineage_fields(&row.full);
1546 if prior_id
1547 .as_ref()
1548 .is_some_and(|p| ctx.index.cards.contains_key(p) && !acc.visited.contains(p))
1549 {
1550 acc.truncated = true;
1551 }
1552 }
1553}
1554
1555fn walk_down(start_id: &str, ctx: &LineageCtx<'_>, acc: &mut LineageAccum) {
1558 let mut frontier: Vec<String> = vec![start_id.to_string()];
1559
1560 for depth in 1..=ctx.max_depth {
1561 let mut next_frontier: Vec<String> = Vec::new();
1562 for parent_id in &frontier {
1563 let children = match ctx.index.children.get(parent_id) {
1564 Some(c) => c,
1565 None => continue,
1566 };
1567 for child_id in children {
1568 if acc.visited.contains(child_id) {
1569 continue;
1570 }
1571 let Some(child) = ctx.index.cards.get(child_id) else {
1572 continue;
1573 };
1574 let (_, prior_rel) = lineage_fields(&child.full);
1575 if !relation_passes(ctx.relation_filter, &prior_rel) {
1576 continue;
1577 }
1578 acc.nodes
1579 .push(make_node(child, depth as i32, ctx.include_stats));
1580 acc.edges.push(LineageEdge {
1581 from: child.summary.card_id.clone(),
1582 to: parent_id.clone(),
1583 relation: prior_rel,
1584 });
1585 acc.visited.insert(child_id.clone());
1586 next_frontier.push(child_id.clone());
1587 }
1588 }
1589 if next_frontier.is_empty() {
1590 return;
1591 }
1592 frontier = next_frontier;
1593 }
1594 for parent_id in &frontier {
1597 let children = match ctx.index.children.get(parent_id) {
1598 Some(c) => c,
1599 None => continue,
1600 };
1601 for child_id in children {
1602 if acc.visited.contains(child_id) {
1603 continue;
1604 }
1605 let Some(child) = ctx.index.cards.get(child_id) else {
1606 continue;
1607 };
1608 let (_, prior_rel) = lineage_fields(&child.full);
1609 if relation_passes(ctx.relation_filter, &prior_rel) {
1610 acc.truncated = true;
1611 return;
1612 }
1613 }
1614 }
1615}
1616
1617pub fn lineage(q: LineageQuery) -> Result<Option<LineageResult>, String> {
1619 let index = load_card_index()?;
1620 let Some(root_row) = index.cards.get(&q.card_id) else {
1621 return Ok(None);
1622 };
1623
1624 let ctx = LineageCtx {
1625 index: &index,
1626 relation_filter: &q.relation_filter,
1627 include_stats: q.include_stats,
1628 max_depth: q.depth.unwrap_or(DEFAULT_LINEAGE_DEPTH),
1629 };
1630 let mut acc = LineageAccum {
1631 nodes: Vec::new(),
1632 edges: Vec::new(),
1633 visited: std::collections::HashSet::new(),
1634 truncated: false,
1635 };
1636
1637 acc.nodes.push(make_node(root_row, 0, q.include_stats));
1638 acc.visited.insert(q.card_id.clone());
1639
1640 if matches!(q.direction, LineageDirection::Up | LineageDirection::Both) {
1641 walk_up(&q.card_id, &ctx, &mut acc);
1642 }
1643 if matches!(q.direction, LineageDirection::Down | LineageDirection::Both) {
1644 walk_down(&q.card_id, &ctx, &mut acc);
1645 }
1646
1647 Ok(Some(LineageResult {
1648 root: q.card_id,
1649 nodes: acc.nodes,
1650 edges: acc.edges,
1651 truncated: acc.truncated,
1652 }))
1653}
1654
1655pub fn lineage_to_json(r: &LineageResult) -> Json {
1657 let nodes: Vec<Json> = r
1658 .nodes
1659 .iter()
1660 .map(|n| {
1661 let mut m = serde_json::Map::new();
1662 m.insert("card_id".into(), json!(n.card_id));
1663 m.insert("pkg".into(), json!(n.pkg));
1664 m.insert("depth".into(), json!(n.depth));
1665 if let Some(p) = &n.prior_card_id {
1666 m.insert("prior_card_id".into(), json!(p));
1667 }
1668 if let Some(rel) = &n.prior_relation {
1669 m.insert("prior_relation".into(), json!(rel));
1670 }
1671 if let Some(s) = &n.stats {
1672 m.insert("stats".into(), s.clone());
1673 }
1674 Json::Object(m)
1675 })
1676 .collect();
1677 let edges: Vec<Json> = r
1678 .edges
1679 .iter()
1680 .map(|e| {
1681 let mut m = serde_json::Map::new();
1682 m.insert("from".into(), json!(e.from));
1683 m.insert("to".into(), json!(e.to));
1684 if let Some(rel) = &e.relation {
1685 m.insert("relation".into(), json!(rel));
1686 }
1687 Json::Object(m)
1688 })
1689 .collect();
1690 json!({
1691 "root": r.root,
1692 "nodes": nodes,
1693 "edges": edges,
1694 "truncated": r.truncated,
1695 })
1696}
1697
1698pub fn import_from_dir(
1718 source_dir: &std::path::Path,
1719 pkg: &str,
1720) -> Result<(Vec<String>, Vec<String>), String> {
1721 validate_name(pkg, "pkg")?;
1722 let dest = pkg_dir(pkg)?;
1723 let mut imported = Vec::new();
1724 let mut skipped = Vec::new();
1725
1726 let entries =
1727 fs::read_dir(source_dir).map_err(|e| format!("Failed to read card source dir: {e}"))?;
1728
1729 for entry in entries.flatten() {
1730 let path = entry.path();
1731 let fname = match path.file_name().and_then(|n| n.to_str()) {
1732 Some(n) => n.to_string(),
1733 None => continue,
1734 };
1735
1736 if !fname.ends_with(".toml") {
1738 continue;
1739 }
1740
1741 let card_id = fname.trim_end_matches(".toml");
1742 let dest_toml = dest.join(&fname);
1743
1744 if dest_toml.exists() {
1745 skipped.push(card_id.to_string());
1746 continue;
1747 }
1748
1749 let text = fs::read_to_string(&path)
1751 .map_err(|e| format!("Failed to read card file '{fname}': {e}"))?;
1752 let val: toml::Value = toml::from_str(&text)
1753 .map_err(|e| format!("Failed to parse card file '{fname}': {e}"))?;
1754 if val.get("schema_version").and_then(|v| v.as_str()) != Some(SCHEMA_VERSION) {
1755 continue; }
1757
1758 fs::copy(&path, &dest_toml).map_err(|e| format!("Failed to copy card '{fname}': {e}"))?;
1760
1761 let samples_name = format!("{card_id}.samples.jsonl");
1763 let samples_src = source_dir.join(&samples_name);
1764 if samples_src.exists() {
1765 let samples_dest = dest.join(&samples_name);
1766 if !samples_dest.exists() {
1767 fs::copy(&samples_src, &samples_dest)
1768 .map_err(|e| format!("Failed to copy samples '{samples_name}': {e}"))?;
1769 }
1770 }
1771
1772 imported.push(card_id.to_string());
1773 }
1774
1775 Ok((imported, skipped))
1776}
1777
1778fn samples_path(card_id: &str) -> Result<PathBuf, String> {
1783 let card_path =
1784 find_card_path(card_id)?.ok_or_else(|| format!("card '{card_id}' not found"))?;
1785 let dir = card_path
1786 .parent()
1787 .ok_or_else(|| format!("card '{card_id}' has no parent directory"))?;
1788 Ok(dir.join(format!("{card_id}.samples.jsonl")))
1789}
1790
1791pub fn write_samples(card_id: &str, samples: Vec<Json>) -> Result<PathBuf, String> {
1797 let path = samples_path(card_id)?;
1798 if path.exists() {
1799 return Err(format!(
1800 "alc.card.write_samples: samples already exist for card '{card_id}' (write-once)"
1801 ));
1802 }
1803 let mut buf = String::new();
1804 for (idx, s) in samples.iter().enumerate() {
1805 let line = serde_json::to_string(s).map_err(|e| {
1806 format!("alc.card.write_samples: failed to serialize sample #{idx}: {e}")
1807 })?;
1808 buf.push_str(&line);
1809 buf.push('\n');
1810 }
1811 let tmp = path.with_extension("jsonl.tmp");
1812 fs::write(&tmp, &buf).map_err(|e| format!("Failed to write samples tmp: {e}"))?;
1813 fs::rename(&tmp, &path).map_err(|e| format!("Failed to rename samples file: {e}"))?;
1814 Ok(path)
1815}
1816
1817#[derive(Debug, Default, Clone)]
1819pub struct SamplesQuery {
1820 pub offset: usize,
1822 pub limit: Option<usize>,
1824 pub where_: Option<Predicate>,
1827}
1828
1829pub fn read_samples(card_id: &str, q: SamplesQuery) -> Result<Vec<Json>, String> {
1839 let path = samples_path(card_id)?;
1840 if !path.exists() {
1841 return Ok(Vec::new());
1842 }
1843 let text =
1844 fs::read_to_string(&path).map_err(|e| format!("Failed to read samples file: {e}"))?;
1845 let mut matched: usize = 0;
1846 let mut out = Vec::new();
1847 for (i, line) in text.lines().enumerate() {
1848 if line.trim().is_empty() {
1849 continue;
1850 }
1851 let val: Json = serde_json::from_str(line)
1852 .map_err(|e| format!("Failed to parse sample line {i}: {e}"))?;
1853 if let Some(pred) = &q.where_ {
1854 if !eval_predicate(pred, &val) {
1855 continue;
1856 }
1857 }
1858 if matched < q.offset {
1859 matched += 1;
1860 continue;
1861 }
1862 if let Some(lim) = q.limit {
1863 if out.len() >= lim {
1864 break;
1865 }
1866 }
1867 matched += 1;
1868 out.push(val);
1869 }
1870 Ok(out)
1871}
1872
1873#[cfg(test)]
1874mod tests {
1875 use super::*;
1876
1877 fn unique_pkg() -> String {
1878 let ns = std::time::SystemTime::now()
1879 .duration_since(std::time::UNIX_EPOCH)
1880 .unwrap()
1881 .as_nanos();
1882 format!("_test_card_{ns}")
1883 }
1884
1885 fn cleanup(pkg: &str) {
1886 if let Ok(d) = pkg_dir(pkg) {
1887 let _ = fs::remove_dir_all(&d);
1888 }
1889 }
1890
1891 #[test]
1892 fn minimum_valid_card() {
1893 let pkg = unique_pkg();
1894 let input = json!({ "pkg": { "name": pkg } });
1895 let (id, path) = create(input).unwrap();
1896 assert!(path.exists());
1897 assert!(id.starts_with(&pkg));
1898
1899 let got = get(&id).unwrap().unwrap();
1900 assert_eq!(got["schema_version"], json!(SCHEMA_VERSION));
1901 assert_eq!(got["card_id"], json!(id));
1902 assert_eq!(got["pkg"]["name"], json!(pkg));
1903 assert!(got.get("created_at").is_some());
1904 assert!(got.get("created_by").is_some());
1905
1906 cleanup(&pkg);
1907 }
1908
1909 #[test]
1910 fn create_rejects_missing_pkg_name() {
1911 let err = create(json!({})).unwrap_err();
1912 assert!(err.contains("pkg.name"));
1913 }
1914
1915 #[test]
1916 fn create_is_immutable() {
1917 let pkg = unique_pkg();
1918 let input = json!({
1919 "card_id": "fixed_id_001",
1920 "pkg": { "name": pkg }
1921 });
1922 create(input.clone()).unwrap();
1923 let err = create(input).unwrap_err();
1924 assert!(err.contains("already exists"));
1925 cleanup(&pkg);
1926 }
1927
1928 #[test]
1929 fn create_injects_param_fingerprint() {
1930 let pkg = unique_pkg();
1931 let input = json!({
1932 "pkg": { "name": pkg },
1933 "params": { "depth": 3, "temperature": 0.0 }
1934 });
1935 let (id, _) = create(input).unwrap();
1936 let got = get(&id).unwrap().unwrap();
1937 assert!(got["param_fingerprint"].is_string());
1938 cleanup(&pkg);
1939 }
1940
1941 #[test]
1942 fn list_returns_newest_first() {
1943 let pkg = unique_pkg();
1944 let (id1, _) = create(json!({
1946 "card_id": format!("{pkg}_a"),
1947 "pkg": { "name": pkg },
1948 "created_at": "2025-01-01T00:00:00Z"
1949 }))
1950 .unwrap();
1951 let (id2, _) = create(json!({
1952 "card_id": format!("{pkg}_b"),
1953 "pkg": { "name": pkg },
1954 "created_at": "2026-01-01T00:00:00Z"
1955 }))
1956 .unwrap();
1957
1958 let rows = list(Some(&pkg)).unwrap();
1959 assert_eq!(rows.len(), 2);
1960 assert_eq!(rows[0].card_id, id2); assert_eq!(rows[1].card_id, id1);
1962
1963 cleanup(&pkg);
1964 }
1965
1966 #[test]
1967 fn list_extracts_summary_fields() {
1968 let pkg = unique_pkg();
1969 let (id, _) = create(json!({
1970 "pkg": { "name": pkg },
1971 "model": { "id": "claude-opus-4-6" },
1972 "scenario": { "name": "gsm8k_sample100" },
1973 "stats": { "pass_rate": 0.82 }
1974 }))
1975 .unwrap();
1976
1977 let rows = list(Some(&pkg)).unwrap();
1978 let row = rows.iter().find(|r| r.card_id == id).unwrap();
1979 assert_eq!(row.model.as_deref(), Some("claude-opus-4-6"));
1980 assert_eq!(row.scenario.as_deref(), Some("gsm8k_sample100"));
1981 assert_eq!(row.pass_rate, Some(0.82));
1982
1983 cleanup(&pkg);
1984 }
1985
1986 #[test]
1987 fn get_missing_returns_none() {
1988 assert!(get("does_not_exist_xyz").unwrap().is_none());
1989 }
1990
1991 #[test]
1992 fn card_id_embeds_compact_timestamp() {
1993 let pkg = unique_pkg();
1994 let (id, _) = create(json!({ "pkg": { "name": pkg } })).unwrap();
1995 let tail = id.strip_prefix(&format!("{pkg}_")).unwrap();
1999 let parts: Vec<&str> = tail.split('_').collect();
2000 assert_eq!(parts.len(), 3, "unexpected card_id shape: {id}");
2002 let ts = parts[1];
2003 assert_eq!(ts.len(), 15, "timestamp segment wrong length: {ts}");
2004 assert!(ts.chars().nth(8) == Some('T'), "missing T separator: {ts}");
2005 cleanup(&pkg);
2006 }
2007
2008 #[test]
2009 fn now_compact_format() {
2010 let s = now_compact();
2011 assert_eq!(s.len(), 15);
2012 assert_eq!(s.chars().nth(8), Some('T'));
2013 for (i, c) in s.chars().enumerate() {
2015 if i != 8 {
2016 assert!(c.is_ascii_digit(), "non-digit at pos {i}: {s}");
2017 }
2018 }
2019 }
2020
2021 #[test]
2022 fn short_model_variants() {
2023 assert_eq!(short_model("claude-opus-4-6"), "opus46");
2024 assert_eq!(short_model("gpt-4o"), "4o");
2025 assert_eq!(short_model(""), "model");
2026 }
2027
2028 #[test]
2029 fn two_cards_same_second_different_stats_get_distinct_ids() {
2030 let pkg = unique_pkg();
2031 let input1 = json!({
2032 "pkg": { "name": pkg },
2033 "scenario": { "name": "gsm8k" },
2034 "stats": { "pass_rate": 0.4 }
2035 });
2036 let input2 = json!({
2037 "pkg": { "name": pkg },
2038 "scenario": { "name": "gsm8k" },
2039 "stats": { "pass_rate": 0.9 }
2040 });
2041 let (id1, _) = create(input1).unwrap();
2042 let (id2, _) = create(input2).unwrap();
2043 assert_ne!(id1, id2, "distinct stats must yield distinct card_ids");
2044 cleanup(&pkg);
2045 }
2046
2047 #[test]
2050 fn append_adds_new_fields() {
2051 let pkg = unique_pkg();
2052 let (id, _) = create(json!({
2053 "pkg": { "name": pkg },
2054 "stats": { "pass_rate": 0.5 }
2055 }))
2056 .unwrap();
2057
2058 let merged = append(
2059 &id,
2060 json!({
2061 "caveats": { "notes": "rescored after fix" },
2062 "metadata": { "reviewer": "yn" }
2063 }),
2064 )
2065 .unwrap();
2066 assert_eq!(merged["caveats"]["notes"], json!("rescored after fix"));
2067 assert_eq!(merged["metadata"]["reviewer"], json!("yn"));
2068
2069 let got = get(&id).unwrap().unwrap();
2071 assert_eq!(got["caveats"]["notes"], json!("rescored after fix"));
2072 assert_eq!(got["stats"]["pass_rate"], json!(0.5));
2074
2075 cleanup(&pkg);
2076 }
2077
2078 #[test]
2079 fn append_rejects_existing_key() {
2080 let pkg = unique_pkg();
2081 let (id, _) = create(json!({
2082 "pkg": { "name": pkg },
2083 "stats": { "pass_rate": 0.5 }
2084 }))
2085 .unwrap();
2086
2087 let err = append(&id, json!({ "stats": { "pass_rate": 0.9 } })).unwrap_err();
2088 assert!(err.contains("already set"), "got: {err}");
2089 let got = get(&id).unwrap().unwrap();
2091 assert_eq!(got["stats"]["pass_rate"], json!(0.5));
2092
2093 cleanup(&pkg);
2094 }
2095
2096 #[test]
2097 fn append_errors_on_missing_card() {
2098 let err = append("does_not_exist_xyz", json!({ "x": 1 })).unwrap_err();
2099 assert!(err.contains("not found"));
2100 }
2101
2102 #[test]
2105 fn alias_set_and_list_roundtrip() {
2106 let pkg = unique_pkg();
2107 let (id, _) = create(json!({ "pkg": { "name": pkg } })).unwrap();
2108
2109 let alias_name = format!("test_alias_{}", &pkg);
2110 alias_set(&alias_name, &id, Some(&pkg), Some("smoke")).unwrap();
2111
2112 let rows = alias_list(Some(&pkg)).unwrap();
2113 let a = rows.iter().find(|a| a.name == alias_name).unwrap();
2114 assert_eq!(a.card_id, id);
2115 assert_eq!(a.pkg.as_deref(), Some(pkg.as_str()));
2116 assert_eq!(a.note.as_deref(), Some("smoke"));
2117 assert!(!a.set_at.is_empty());
2118
2119 let (id2, _) = create(json!({
2121 "card_id": format!("{pkg}_b"),
2122 "pkg": { "name": pkg }
2123 }))
2124 .unwrap();
2125 alias_set(&alias_name, &id2, Some(&pkg), None).unwrap();
2126 let rows = alias_list(Some(&pkg)).unwrap();
2127 let matching: Vec<&Alias> = rows.iter().filter(|a| a.name == alias_name).collect();
2128 assert_eq!(matching.len(), 1, "alias should be unique by name");
2129 assert_eq!(matching[0].card_id, id2);
2130
2131 let remaining: Vec<Alias> = read_aliases()
2133 .unwrap()
2134 .into_iter()
2135 .filter(|a| a.name != alias_name)
2136 .collect();
2137 write_aliases(&remaining).unwrap();
2138 cleanup(&pkg);
2139 }
2140
2141 #[test]
2142 fn alias_set_rejects_unknown_card() {
2143 let err = alias_set("x", "does_not_exist_xyz", None, None).unwrap_err();
2144 assert!(err.contains("not found"));
2145 }
2146
2147 fn where_from(v: Json) -> Predicate {
2150 parse_where(&v).expect("parse where")
2151 }
2152
2153 fn order_from(v: Json) -> Vec<OrderKey> {
2154 parse_order_by(&v).expect("parse order_by")
2155 }
2156
2157 #[test]
2158 fn find_where_nested_eq_and_gte() {
2159 let pkg = unique_pkg();
2160 create(json!({
2161 "card_id": format!("{pkg}_low"),
2162 "pkg": { "name": pkg },
2163 "scenario": { "name": "gsm8k" },
2164 "stats": { "pass_rate": 0.4 }
2165 }))
2166 .unwrap();
2167 create(json!({
2168 "card_id": format!("{pkg}_high"),
2169 "pkg": { "name": pkg },
2170 "scenario": { "name": "gsm8k" },
2171 "stats": { "pass_rate": 0.9 }
2172 }))
2173 .unwrap();
2174 create(json!({
2175 "card_id": format!("{pkg}_other"),
2176 "pkg": { "name": pkg },
2177 "scenario": { "name": "other" },
2178 "stats": { "pass_rate": 1.0 }
2179 }))
2180 .unwrap();
2181
2182 let rows = find(FindQuery {
2184 pkg: Some(pkg.clone()),
2185 where_: Some(where_from(json!({
2186 "scenario": { "name": "gsm8k" },
2187 }))),
2188 order_by: order_from(json!("-stats.pass_rate")),
2189 ..Default::default()
2190 })
2191 .unwrap();
2192 assert_eq!(rows.len(), 2);
2193 assert_eq!(rows[0].pass_rate, Some(0.9));
2194 assert_eq!(rows[1].pass_rate, Some(0.4));
2195
2196 let rows = find(FindQuery {
2198 pkg: Some(pkg.clone()),
2199 where_: Some(where_from(json!({
2200 "stats": { "pass_rate": { "gte": 0.8 } },
2201 }))),
2202 order_by: order_from(json!("-stats.pass_rate")),
2203 ..Default::default()
2204 })
2205 .unwrap();
2206 assert_eq!(rows.len(), 2);
2207 assert!(rows.iter().all(|r| r.pass_rate.unwrap() >= 0.8));
2208
2209 let rows = find(FindQuery {
2211 pkg: Some(pkg.clone()),
2212 order_by: order_from(json!("-stats.pass_rate")),
2213 limit: Some(1),
2214 ..Default::default()
2215 })
2216 .unwrap();
2217 assert_eq!(rows.len(), 1);
2218 assert_eq!(rows[0].pass_rate, Some(1.0));
2219
2220 cleanup(&pkg);
2221 }
2222
2223 #[test]
2224 fn find_where_implicit_eq_and_logical() {
2225 let pkg = unique_pkg();
2226 create(json!({
2227 "card_id": format!("{pkg}_a"),
2228 "pkg": { "name": pkg },
2229 "model": { "id": "claude-opus-4-6" },
2230 "stats": { "equilibrium_position": "dead", "survival_rate": 0.0 }
2231 }))
2232 .unwrap();
2233 create(json!({
2234 "card_id": format!("{pkg}_b"),
2235 "pkg": { "name": pkg },
2236 "model": { "id": "claude-opus-4-6" },
2237 "stats": { "equilibrium_position": "niche_leader", "survival_rate": 1.0 }
2238 }))
2239 .unwrap();
2240 create(json!({
2241 "card_id": format!("{pkg}_c"),
2242 "pkg": { "name": pkg },
2243 "model": { "id": "claude-haiku-4-5-20251001" },
2244 "stats": { "equilibrium_position": "fragile", "survival_rate": 0.2 }
2245 }))
2246 .unwrap();
2247
2248 let rows = find(FindQuery {
2250 pkg: Some(pkg.clone()),
2251 where_: Some(where_from(json!({
2252 "stats": { "equilibrium_position": "dead" },
2253 }))),
2254 ..Default::default()
2255 })
2256 .unwrap();
2257 assert_eq!(rows.len(), 1);
2258 assert!(rows[0].card_id.ends_with("_a"));
2259
2260 let rows = find(FindQuery {
2262 pkg: Some(pkg.clone()),
2263 where_: Some(where_from(json!({
2264 "_or": [
2265 { "stats": { "equilibrium_position": "dead" } },
2266 { "stats": { "survival_rate": { "gte": 0.9 } } },
2267 ],
2268 }))),
2269 ..Default::default()
2270 })
2271 .unwrap();
2272 assert_eq!(rows.len(), 2);
2273
2274 let rows = find(FindQuery {
2276 pkg: Some(pkg.clone()),
2277 where_: Some(where_from(json!({
2278 "_not": { "model": { "id": "claude-haiku-4-5-20251001" } },
2279 }))),
2280 ..Default::default()
2281 })
2282 .unwrap();
2283 assert_eq!(rows.len(), 2);
2284
2285 let rows = find(FindQuery {
2287 pkg: Some(pkg.clone()),
2288 where_: Some(where_from(json!({
2289 "stats": {
2290 "equilibrium_position": { "in": ["dead", "fragile"] },
2291 },
2292 }))),
2293 ..Default::default()
2294 })
2295 .unwrap();
2296 assert_eq!(rows.len(), 2);
2297
2298 let rows = find(FindQuery {
2301 pkg: Some(pkg.clone()),
2302 where_: Some(where_from(json!({
2303 "strategy_params": { "temperature": { "exists": false } },
2304 }))),
2305 ..Default::default()
2306 })
2307 .unwrap();
2308 assert_eq!(rows.len(), 3, "none of the cards have strategy_params");
2309
2310 cleanup(&pkg);
2311 }
2312
2313 #[test]
2314 fn find_order_by_multi_key() {
2315 let pkg = unique_pkg();
2316 create(json!({
2317 "card_id": format!("{pkg}_a"),
2318 "pkg": { "name": pkg },
2319 "stats": { "pass_rate": 0.5 }
2320 }))
2321 .unwrap();
2322 create(json!({
2323 "card_id": format!("{pkg}_b"),
2324 "pkg": { "name": pkg },
2325 "stats": { "pass_rate": 0.9 }
2326 }))
2327 .unwrap();
2328 create(json!({
2329 "card_id": format!("{pkg}_c"),
2330 "pkg": { "name": pkg },
2331 "stats": { "pass_rate": 0.9 }
2332 }))
2333 .unwrap();
2334
2335 let rows = find(FindQuery {
2336 pkg: Some(pkg.clone()),
2337 order_by: order_from(json!(["-stats.pass_rate", "card_id"])),
2338 ..Default::default()
2339 })
2340 .unwrap();
2341 assert_eq!(rows.len(), 3);
2342 assert_eq!(rows[0].pass_rate, Some(0.9));
2343 assert_eq!(rows[1].pass_rate, Some(0.9));
2344 assert_eq!(rows[2].pass_rate, Some(0.5));
2345 assert!(rows[0].card_id < rows[1].card_id);
2347
2348 cleanup(&pkg);
2349 }
2350
2351 #[test]
2352 fn find_offset_and_limit() {
2353 let pkg = unique_pkg();
2354 for i in 0..5 {
2355 create(json!({
2356 "card_id": format!("{pkg}_{i}"),
2357 "pkg": { "name": pkg },
2358 "stats": { "pass_rate": 0.1 * (i + 1) as f64 }
2359 }))
2360 .unwrap();
2361 }
2362
2363 let rows = find(FindQuery {
2364 pkg: Some(pkg.clone()),
2365 order_by: order_from(json!("-stats.pass_rate")),
2366 offset: Some(1),
2367 limit: Some(2),
2368 ..Default::default()
2369 })
2370 .unwrap();
2371 assert_eq!(rows.len(), 2);
2372 let pr0 = rows[0].pass_rate.unwrap();
2374 let pr1 = rows[1].pass_rate.unwrap();
2375 assert!((pr0 - 0.4).abs() < 1e-9, "got {pr0}");
2376 assert!((pr1 - 0.3).abs() < 1e-9, "got {pr1}");
2377
2378 cleanup(&pkg);
2379 }
2380
2381 #[test]
2382 fn parse_where_rejects_non_object() {
2383 assert!(parse_where(&json!("not an object")).is_err());
2384 assert!(parse_where(&json!(42)).is_err());
2385 }
2386
2387 #[test]
2388 fn parse_order_by_accepts_string_and_array() {
2389 let k = parse_order_by(&json!("-stats.pass_rate")).unwrap();
2390 assert_eq!(k.len(), 1);
2391 assert_eq!(k[0].path, vec!["stats", "pass_rate"]);
2392 assert!(k[0].desc);
2393
2394 let k = parse_order_by(&json!(["created_at", "-stats.n"])).unwrap();
2395 assert_eq!(k.len(), 2);
2396 assert!(!k[0].desc);
2397 assert!(k[1].desc);
2398 }
2399
2400 #[test]
2401 fn find_where_string_ops_contains_and_starts_with() {
2402 let pkg = unique_pkg();
2403 create(json!({
2404 "card_id": format!("{pkg}_a"),
2405 "pkg": { "name": pkg },
2406 "model": { "id": "claude-opus-4-6" },
2407 "metadata": { "tag": "experiment_alpha" },
2408 }))
2409 .unwrap();
2410 create(json!({
2411 "card_id": format!("{pkg}_b"),
2412 "pkg": { "name": pkg },
2413 "model": { "id": "claude-haiku-4-5-20251001" },
2414 "metadata": { "tag": "experiment_beta" },
2415 }))
2416 .unwrap();
2417 create(json!({
2418 "card_id": format!("{pkg}_c"),
2419 "pkg": { "name": pkg },
2420 "model": { "id": "claude-sonnet-4-5" },
2421 "metadata": { "tag": "baseline" },
2422 }))
2423 .unwrap();
2424
2425 let rows = find(FindQuery {
2427 pkg: Some(pkg.clone()),
2428 where_: Some(where_from(json!({
2429 "metadata": { "tag": { "contains": "experiment" } },
2430 }))),
2431 ..Default::default()
2432 })
2433 .unwrap();
2434 assert_eq!(rows.len(), 2);
2435
2436 let rows = find(FindQuery {
2438 pkg: Some(pkg.clone()),
2439 where_: Some(where_from(json!({
2440 "model": { "id": { "starts_with": "claude-opus" } },
2441 }))),
2442 ..Default::default()
2443 })
2444 .unwrap();
2445 assert_eq!(rows.len(), 1);
2446 assert!(rows[0].card_id.ends_with("_a"));
2447
2448 let rows = find(FindQuery {
2450 pkg: Some(pkg.clone()),
2451 where_: Some(where_from(json!({
2452 "metadata": { "missing_field": { "contains": "x" } },
2453 }))),
2454 ..Default::default()
2455 })
2456 .unwrap();
2457 assert_eq!(rows.len(), 0);
2458
2459 let rows = find(FindQuery {
2461 pkg: Some(pkg.clone()),
2462 where_: Some(where_from(json!({
2463 "metadata": { "tag": { "starts_with": 42 } },
2464 }))),
2465 ..Default::default()
2466 })
2467 .unwrap();
2468 assert_eq!(rows.len(), 0);
2469
2470 cleanup(&pkg);
2471 }
2472
2473 #[test]
2474 fn where_missing_field_ne_is_true() {
2475 let pkg = unique_pkg();
2476 create(json!({
2477 "card_id": format!("{pkg}_x"),
2478 "pkg": { "name": pkg },
2479 }))
2480 .unwrap();
2481
2482 let rows = find(FindQuery {
2483 pkg: Some(pkg.clone()),
2484 where_: Some(where_from(json!({
2485 "strategy_params": { "temperature": { "ne": 0.5 } },
2486 }))),
2487 ..Default::default()
2488 })
2489 .unwrap();
2490 assert_eq!(rows.len(), 1, "missing field is ne to anything");
2491
2492 cleanup(&pkg);
2493 }
2494
2495 fn create_child(pkg: &str, suffix: &str, parent_id: &str, relation: &str) -> String {
2499 let (id, _) = create(json!({
2500 "card_id": format!("{pkg}_{suffix}"),
2501 "pkg": { "name": pkg },
2502 "stats": { "pass_rate": 0.5 },
2503 "metadata": {
2504 "prior_card_id": parent_id,
2505 "prior_relation": relation,
2506 },
2507 }))
2508 .unwrap();
2509 id
2510 }
2511
2512 #[test]
2513 fn lineage_up_walks_prior_card_id_chain() {
2514 let pkg = unique_pkg();
2515 let (a, _) = create(json!({
2517 "card_id": format!("{pkg}_a"),
2518 "pkg": { "name": pkg },
2519 }))
2520 .unwrap();
2521 let b = create_child(&pkg, "b", &a, "rerun_of");
2522 let c = create_child(&pkg, "c", &b, "rerun_of");
2523
2524 let res = lineage(LineageQuery {
2525 card_id: c.clone(),
2526 direction: LineageDirection::Up,
2527 depth: None,
2528 include_stats: false,
2529 relation_filter: None,
2530 })
2531 .unwrap()
2532 .expect("lineage result");
2533
2534 assert_eq!(res.root, c);
2535 assert_eq!(res.nodes.len(), 3, "root + 2 ancestors");
2536 assert_eq!(res.nodes[0].card_id, c);
2537 assert_eq!(res.nodes[0].depth, 0);
2538 assert_eq!(res.nodes[1].card_id, b);
2539 assert_eq!(res.nodes[1].depth, -1);
2540 assert_eq!(res.nodes[2].card_id, a);
2541 assert_eq!(res.nodes[2].depth, -2);
2542 assert_eq!(res.edges.len(), 2);
2543 assert!(!res.truncated);
2544
2545 cleanup(&pkg);
2546 }
2547
2548 #[test]
2549 fn lineage_down_walks_descendants_breadth_first() {
2550 let pkg = unique_pkg();
2551 let (a, _) = create(json!({
2553 "card_id": format!("{pkg}_a"),
2554 "pkg": { "name": pkg },
2555 }))
2556 .unwrap();
2557 let _b = create_child(&pkg, "b", &a, "sweep_variant");
2558 let c = create_child(&pkg, "c", &a, "sweep_variant");
2559 let _d = create_child(&pkg, "d", &c, "rerun_of");
2560
2561 let res = lineage(LineageQuery {
2562 card_id: a.clone(),
2563 direction: LineageDirection::Down,
2564 depth: None,
2565 include_stats: false,
2566 relation_filter: None,
2567 })
2568 .unwrap()
2569 .expect("lineage result");
2570
2571 assert_eq!(res.nodes.len(), 4);
2573 assert_eq!(res.edges.len(), 3);
2574 assert!(!res.truncated);
2575
2576 cleanup(&pkg);
2577 }
2578
2579 #[test]
2580 fn lineage_depth_truncation_sets_flag() {
2581 let pkg = unique_pkg();
2582 let (a, _) = create(json!({
2583 "card_id": format!("{pkg}_a"),
2584 "pkg": { "name": pkg },
2585 }))
2586 .unwrap();
2587 let b = create_child(&pkg, "b", &a, "rerun_of");
2588 let _c = create_child(&pkg, "c", &b, "rerun_of");
2589
2590 let res = lineage(LineageQuery {
2591 card_id: a,
2592 direction: LineageDirection::Down,
2593 depth: Some(1),
2594 include_stats: false,
2595 relation_filter: None,
2596 })
2597 .unwrap()
2598 .unwrap();
2599 assert_eq!(res.nodes.len(), 2, "root + 1 level");
2600 assert!(res.truncated, "should be truncated at depth=1");
2601
2602 cleanup(&pkg);
2603 }
2604
2605 #[test]
2606 fn lineage_relation_filter_skips_unlisted() {
2607 let pkg = unique_pkg();
2608 let (a, _) = create(json!({
2609 "card_id": format!("{pkg}_a"),
2610 "pkg": { "name": pkg },
2611 }))
2612 .unwrap();
2613 let _b = create_child(&pkg, "b", &a, "sweep_variant");
2614 let _c = create_child(&pkg, "c", &a, "rerun_of");
2615
2616 let res = lineage(LineageQuery {
2617 card_id: a,
2618 direction: LineageDirection::Down,
2619 depth: None,
2620 include_stats: false,
2621 relation_filter: Some(vec!["sweep_variant".to_string()]),
2622 })
2623 .unwrap()
2624 .unwrap();
2625 assert_eq!(res.nodes.len(), 2, "root + only sweep_variant child");
2626 assert_eq!(res.edges[0].relation.as_deref(), Some("sweep_variant"));
2627
2628 cleanup(&pkg);
2629 }
2630
2631 #[test]
2632 fn lineage_missing_card_returns_none() {
2633 let res = lineage(LineageQuery {
2634 card_id: "nonexistent_card_id_xyz".into(),
2635 direction: LineageDirection::Up,
2636 depth: None,
2637 include_stats: false,
2638 relation_filter: None,
2639 })
2640 .unwrap();
2641 assert!(res.is_none());
2642 }
2643
2644 #[test]
2647 fn write_and_read_samples_roundtrip() {
2648 let pkg = unique_pkg();
2649 let (id, _) = create(json!({
2650 "pkg": { "name": pkg },
2651 "stats": { "pass_rate": 0.5 }
2652 }))
2653 .unwrap();
2654
2655 let samples = vec![
2656 json!({ "case": "c0", "passed": true, "score": 1.0 }),
2657 json!({ "case": "c1", "passed": false, "score": 0.0 }),
2658 json!({ "case": "c2", "passed": true, "score": 0.75 }),
2659 ];
2660 let path = write_samples(&id, samples.clone()).unwrap();
2661 assert!(path.exists());
2662 assert!(path.to_string_lossy().ends_with(".samples.jsonl"));
2663
2664 let got = read_samples(&id, SamplesQuery::default()).unwrap();
2665 assert_eq!(got.len(), 3);
2666 assert_eq!(got[0]["case"], json!("c0"));
2667 assert_eq!(got[2]["score"], json!(0.75));
2668
2669 let slice = read_samples(
2671 &id,
2672 SamplesQuery {
2673 offset: 1,
2674 limit: Some(1),
2675 where_: None,
2676 },
2677 )
2678 .unwrap();
2679 assert_eq!(slice.len(), 1);
2680 assert_eq!(slice[0]["case"], json!("c1"));
2681
2682 cleanup(&pkg);
2683 }
2684
2685 #[test]
2686 fn write_samples_is_write_once() {
2687 let pkg = unique_pkg();
2688 let (id, _) = create(json!({ "pkg": { "name": pkg } })).unwrap();
2689 write_samples(&id, vec![json!({ "x": 1 })]).unwrap();
2690 let err = write_samples(&id, vec![json!({ "x": 2 })]).unwrap_err();
2691 assert!(err.contains("already exist"), "got: {err}");
2692 cleanup(&pkg);
2693 }
2694
2695 #[test]
2696 fn read_samples_empty_when_absent() {
2697 let pkg = unique_pkg();
2698 let (id, _) = create(json!({ "pkg": { "name": pkg } })).unwrap();
2699 let got = read_samples(&id, SamplesQuery::default()).unwrap();
2700 assert!(got.is_empty());
2701 cleanup(&pkg);
2702 }
2703
2704 #[test]
2705 fn read_samples_where_filters_rows() {
2706 let pkg = unique_pkg();
2707 let (id, _) = create(json!({ "pkg": { "name": pkg } })).unwrap();
2708 write_samples(
2709 &id,
2710 vec![
2711 json!({ "case": "c0", "passed": true, "score": 1.0 }),
2712 json!({ "case": "c1", "passed": false, "score": 0.0 }),
2713 json!({ "case": "c2", "passed": true, "score": 0.25 }),
2714 json!({ "case": "c3", "passed": true, "score": 0.75 }),
2715 json!({ "case": "c4", "passed": false, "score": 0.5 }),
2716 ],
2717 )
2718 .unwrap();
2719
2720 let pred = parse_where(&json!({ "passed": true })).unwrap();
2722 let got = read_samples(
2723 &id,
2724 SamplesQuery {
2725 offset: 0,
2726 limit: None,
2727 where_: Some(pred),
2728 },
2729 )
2730 .unwrap();
2731 assert_eq!(got.len(), 3);
2732 assert_eq!(got[0]["case"], json!("c0"));
2733 assert_eq!(got[1]["case"], json!("c2"));
2734 assert_eq!(got[2]["case"], json!("c3"));
2735
2736 let pred = parse_where(&json!({ "score": { "gte": 0.5 } })).unwrap();
2738 let got = read_samples(
2739 &id,
2740 SamplesQuery {
2741 offset: 0,
2742 limit: None,
2743 where_: Some(pred),
2744 },
2745 )
2746 .unwrap();
2747 assert_eq!(got.len(), 3);
2748 assert_eq!(got[0]["case"], json!("c0"));
2749 assert_eq!(got[1]["case"], json!("c3"));
2750 assert_eq!(got[2]["case"], json!("c4"));
2751
2752 let pred = parse_where(&json!({ "passed": true })).unwrap();
2754 let slice = read_samples(
2755 &id,
2756 SamplesQuery {
2757 offset: 1,
2758 limit: Some(1),
2759 where_: Some(pred),
2760 },
2761 )
2762 .unwrap();
2763 assert_eq!(slice.len(), 1);
2764 assert_eq!(slice[0]["case"], json!("c2"));
2765
2766 cleanup(&pkg);
2767 }
2768
2769 #[test]
2770 fn get_by_alias_roundtrip() {
2771 let pkg = unique_pkg();
2772 let (id, _) = create(json!({
2773 "pkg": { "name": pkg },
2774 "stats": { "pass_rate": 0.85 }
2775 }))
2776 .unwrap();
2777
2778 let alias_name = format!("best_{pkg}");
2779 alias_set(&alias_name, &id, Some(&pkg), None).unwrap();
2780
2781 let card = get_by_alias(&alias_name).unwrap().unwrap();
2782 assert_eq!(card["card_id"], json!(id));
2783 assert_eq!(card["stats"]["pass_rate"], json!(0.85));
2784
2785 assert!(get_by_alias("nonexistent_alias_xyz").unwrap().is_none());
2786
2787 cleanup(&pkg);
2788 }
2789
2790 #[test]
2791 fn samples_errors_on_missing_card() {
2792 let err = write_samples("does_not_exist_xyz_samples", vec![json!({})]).unwrap_err();
2793 assert!(err.contains("not found"));
2794 }
2795
2796 #[test]
2799 fn import_from_dir_copies_cards() {
2800 let pkg = unique_pkg();
2801 let tmp = tempfile::tempdir().unwrap();
2802
2803 let card_id = format!("{pkg}_imported");
2805 let card_content = format!(
2806 "schema_version = \"{SCHEMA_VERSION}\"\ncard_id = \"{card_id}\"\npkg = \"{pkg}\"\n"
2807 );
2808 fs::write(tmp.path().join(format!("{card_id}.toml")), &card_content).unwrap();
2809
2810 fs::write(
2812 tmp.path().join(format!("{card_id}.samples.jsonl")),
2813 "{\"case\":\"c0\"}\n",
2814 )
2815 .unwrap();
2816
2817 let (imported, skipped) = import_from_dir(tmp.path(), &pkg).unwrap();
2818 assert_eq!(imported, vec![card_id.clone()]);
2819 assert!(skipped.is_empty());
2820
2821 let got = get(&card_id).unwrap().unwrap();
2823 assert_eq!(got["card_id"], json!(card_id));
2824
2825 let samples = read_samples(&card_id, SamplesQuery::default()).unwrap();
2827 assert_eq!(samples.len(), 1);
2828
2829 cleanup(&pkg);
2830 }
2831
2832 #[test]
2833 fn import_from_dir_skips_existing() {
2834 let pkg = unique_pkg();
2835 let (existing_id, _) = create(json!({
2837 "pkg": { "name": pkg },
2838 "stats": { "pass_rate": 0.5 }
2839 }))
2840 .unwrap();
2841
2842 let tmp = tempfile::tempdir().unwrap();
2844 let card_content = format!(
2845 "schema_version = \"{SCHEMA_VERSION}\"\ncard_id = \"{existing_id}\"\npkg = \"{pkg}\"\n"
2846 );
2847 fs::write(
2848 tmp.path().join(format!("{existing_id}.toml")),
2849 &card_content,
2850 )
2851 .unwrap();
2852
2853 let (imported, skipped) = import_from_dir(tmp.path(), &pkg).unwrap();
2854 assert!(imported.is_empty());
2855 assert_eq!(skipped, vec![existing_id.clone()]);
2856
2857 let got = get(&existing_id).unwrap().unwrap();
2859 assert_eq!(got["stats"]["pass_rate"], json!(0.5));
2860
2861 cleanup(&pkg);
2862 }
2863
2864 #[test]
2865 fn import_from_dir_skips_non_card_toml() {
2866 let pkg = unique_pkg();
2867 let tmp = tempfile::tempdir().unwrap();
2868
2869 fs::write(tmp.path().join("not_a_card.toml"), "title = \"hello\"\n").unwrap();
2871
2872 let (imported, skipped) = import_from_dir(tmp.path(), &pkg).unwrap();
2873 assert!(imported.is_empty());
2874 assert!(skipped.is_empty());
2875
2876 cleanup(&pkg);
2877 }
2878}