1use std::collections::{HashMap, HashSet};
87use std::fs;
88use std::path::{Path, PathBuf};
89use std::process;
90use std::sync::atomic::{AtomicU64, Ordering};
91use std::sync::{Arc, Mutex, OnceLock};
92
93use algocline_core::{LogEntry, LogSink};
94use serde::{Deserialize, Serialize, Serializer};
95use serde_json::{json, Value as Json};
96
97pub const SCHEMA_VERSION: &str = "card/v0";
98
99pub trait CardStore: Send + Sync {
123 fn write_new_card(&self, pkg: &str, card_id: &str, toml_text: &str) -> Result<PathBuf, String>;
134
135 fn overwrite_card(&self, card_id: &str, toml_text: &str) -> Result<PathBuf, String>;
141
142 fn find_card_locator(&self, card_id: &str) -> Result<Option<PathBuf>, String>;
144
145 fn read_card_text(&self, card_id: &str) -> Result<Option<String>, String>;
147
148 fn list_card_locators(
155 &self,
156 pkg_filter: Option<&str>,
157 ) -> Result<Vec<(String, PathBuf)>, String>;
158
159 fn read_locator_text(&self, locator: &Path) -> Result<Option<String>, String>;
163
164 fn read_aliases(&self) -> Result<Vec<Alias>, String>;
167 fn write_aliases(&self, aliases: &[Alias]) -> Result<(), String>;
168
169 fn samples_exists(&self, card_id: &str) -> Result<bool, String>;
173
174 fn write_samples_text(&self, card_id: &str, jsonl_text: &str) -> Result<PathBuf, String>;
180
181 fn read_samples_text(&self, card_id: &str) -> Result<Option<String>, String>;
184
185 fn import_from_dir(
191 &self,
192 source_dir: &Path,
193 pkg: &str,
194 ) -> Result<(Vec<String>, Vec<String>), String>;
195}
196
197pub fn validate_name(name: &str, kind: &str) -> Result<(), String> {
198 if name.is_empty()
199 || name.contains('/')
200 || name.contains('\\')
201 || name.contains("..")
202 || name.contains('\0')
203 {
204 return Err(format!("Invalid {kind} name: '{name}'"));
205 }
206 Ok(())
207}
208
209fn djb2_hex(s: &str) -> String {
211 let mut h: u64 = 5381;
212 for b in s.bytes() {
213 h = h.wrapping_mul(33).wrapping_add(b as u64);
214 }
215 format!("{h:016x}")
216}
217
218fn hash6(s: &str) -> String {
225 let hex = djb2_hex(s);
226 let start = hex.len().saturating_sub(6);
227 hex[start..].to_string()
228}
229
230fn stable_json(v: &Json) -> String {
232 let mut buf = String::new();
233 stable_json_into(v, &mut buf);
234 buf
235}
236fn stable_json_into(v: &Json, buf: &mut String) {
237 match v {
238 Json::Null => buf.push_str("null"),
239 Json::Bool(b) => buf.push_str(if *b { "true" } else { "false" }),
240 Json::Number(n) => buf.push_str(&n.to_string()),
241 Json::String(s) => {
242 buf.push('"');
243 buf.push_str(s);
244 buf.push('"');
245 }
246 Json::Array(a) => {
247 buf.push('[');
248 for (i, item) in a.iter().enumerate() {
249 if i > 0 {
250 buf.push(',');
251 }
252 stable_json_into(item, buf);
253 }
254 buf.push(']');
255 }
256 Json::Object(m) => {
257 let mut keys: Vec<&String> = m.keys().collect();
258 keys.sort();
259 buf.push('{');
260 for (i, k) in keys.iter().enumerate() {
261 if i > 0 {
262 buf.push(',');
263 }
264 buf.push('"');
265 buf.push_str(k);
266 buf.push_str("\":");
267 stable_json_into(&m[*k], buf);
268 }
269 buf.push('}');
270 }
271 }
272}
273
274fn short_model(id: &str) -> String {
277 if id.is_empty() {
278 return "model".into();
279 }
280 let stripped = id
282 .strip_prefix("claude-")
283 .or_else(|| id.strip_prefix("gpt-"))
284 .unwrap_or(id);
285 let s: String = stripped
287 .chars()
288 .filter(|c| c.is_ascii_alphanumeric())
289 .collect();
290 if s.is_empty() {
291 "model".into()
292 } else {
293 s
294 }
295}
296
297fn now_rfc3339() -> String {
299 let secs = std::time::SystemTime::now()
300 .duration_since(std::time::UNIX_EPOCH)
301 .map(|d| d.as_secs())
302 .unwrap_or(0) as i64;
303 let days = secs.div_euclid(86400);
304 let tod = secs.rem_euclid(86400);
305 let (y, mo, d) = civil_from_days(days);
306 let hh = tod / 3600;
307 let mm = (tod % 3600) / 60;
308 let ss = tod % 60;
309 format!("{y:04}-{mo:02}-{d:02}T{hh:02}:{mm:02}:{ss:02}Z")
310}
311
312fn now_compact() -> String {
318 let secs = std::time::SystemTime::now()
319 .duration_since(std::time::UNIX_EPOCH)
320 .map(|d| d.as_secs())
321 .unwrap_or(0) as i64;
322 let days = secs.div_euclid(86400);
323 let tod = secs.rem_euclid(86400);
324 let (y, mo, d) = civil_from_days(days);
325 let hh = tod / 3600;
326 let mm = (tod % 3600) / 60;
327 let ss = tod % 60;
328 format!("{y:04}{mo:02}{d:02}T{hh:02}{mm:02}{ss:02}")
329}
330
331fn civil_from_days(z: i64) -> (i32, u32, u32) {
333 let z = z + 719468;
334 let era = if z >= 0 { z } else { z - 146096 } / 146097;
335 let doe = (z - era * 146097) as u64;
336 let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365;
337 let y = yoe as i64 + era * 400;
338 let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
339 let mp = (5 * doy + 2) / 153;
340 let d = (doy - (153 * mp + 2) / 5 + 1) as u32;
341 let m = (if mp < 10 { mp + 3 } else { mp - 9 }) as u32;
342 let y = y + if m <= 2 { 1 } else { 0 };
343 (y as i32, m, d)
344}
345
346fn json_to_toml(v: Json) -> Result<toml::Value, String> {
349 Ok(match v {
350 Json::Null => return Err("TOML does not support null values".into()),
351 Json::Bool(b) => toml::Value::Boolean(b),
352 Json::Number(n) => {
353 if let Some(i) = n.as_i64() {
354 toml::Value::Integer(i)
355 } else if let Some(f) = n.as_f64() {
356 toml::Value::Float(f)
357 } else {
358 return Err(format!("Unsupported number: {n}"));
359 }
360 }
361 Json::String(s) => toml::Value::String(s),
362 Json::Array(a) => {
363 let mut out = Vec::with_capacity(a.len());
364 for item in a {
365 if !item.is_null() {
366 out.push(json_to_toml(item)?);
367 }
368 }
369 toml::Value::Array(out)
370 }
371 Json::Object(m) => {
372 let mut table = toml::map::Map::new();
373 for (k, val) in m {
374 if val.is_null() {
375 continue;
376 }
377 table.insert(k, json_to_toml(val)?);
378 }
379 toml::Value::Table(table)
380 }
381 })
382}
383
384fn toml_to_json(v: toml::Value) -> Json {
386 match v {
387 toml::Value::String(s) => Json::String(s),
388 toml::Value::Integer(i) => json!(i),
389 toml::Value::Float(f) => json!(f),
390 toml::Value::Boolean(b) => Json::Bool(b),
391 toml::Value::Datetime(dt) => Json::String(dt.to_string()),
392 toml::Value::Array(a) => Json::Array(a.into_iter().map(toml_to_json).collect()),
393 toml::Value::Table(t) => {
394 let mut m = serde_json::Map::new();
395 for (k, v) in t {
396 m.insert(k, toml_to_json(v));
397 }
398 Json::Object(m)
399 }
400 }
401}
402
403fn require_pkg_name(input: &Json) -> Result<String, String> {
405 let name = input
406 .get("pkg")
407 .and_then(|p| p.get("name"))
408 .and_then(|n| n.as_str())
409 .ok_or_else(|| "alc.card.create: pkg.name is required".to_string())?
410 .to_string();
411 validate_name(&name, "pkg")?;
412 Ok(name)
413}
414
415#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
431#[serde(rename_all = "snake_case")]
432pub enum RunStatus {
433 Succeeded,
434 Failed,
435 Skipped,
436}
437
438#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
448pub struct RunSection {
449 pub status: RunStatus,
450 #[serde(skip_serializing_if = "Option::is_none", default)]
451 pub reason: Option<String>,
452 #[serde(skip_serializing_if = "Option::is_none", default)]
453 pub action: Option<String>,
454}
455
456impl RunSection {
457 pub fn from_json(input: &Json) -> Result<Option<RunSection>, String> {
466 let Some(run) = input.get("run") else {
467 return Ok(None);
468 };
469 serde_json::from_value::<RunSection>(run.clone())
470 .map(Some)
471 .map_err(|e| {
472 format!(
473 "alc.card: invalid run field (status must be one of: \
474 succeeded, failed, skipped): {e}"
475 )
476 })
477 }
478}
479
480pub fn create_with_store(
482 store: &dyn CardStore,
483 mut input: Json,
484) -> Result<(String, PathBuf), String> {
485 if !input.is_object() {
486 return Err("alc.card.create: input must be a table".into());
487 }
488 let pkg_name = require_pkg_name(&input)?;
489 let obj = input.as_object_mut().unwrap();
490
491 obj.entry("schema_version".to_string())
493 .or_insert_with(|| json!(SCHEMA_VERSION));
494 obj.entry("created_at".to_string())
495 .or_insert_with(|| json!(now_rfc3339()));
496 obj.entry("created_by".to_string())
497 .or_insert_with(|| json!(format!("alc@{}", env!("CARGO_PKG_VERSION"))));
498
499 if let Some(params) = obj.get("params").cloned() {
501 if params.is_object() {
502 let fp = djb2_hex(&stable_json(¶ms));
503 obj.insert("param_fingerprint".to_string(), json!(fp));
504 }
505 }
506
507 let card_id = match obj.get("card_id").and_then(|v| v.as_str()) {
509 Some(id) if !id.is_empty() => id.to_string(),
510 _ => {
511 let model_id = obj
512 .get("model")
513 .and_then(|m| m.get("id"))
514 .and_then(|v| v.as_str())
515 .unwrap_or("");
516 let model_short = short_model(model_id);
517 let ts = now_compact();
518 let fp_seed = stable_json(&Json::Object(obj.clone()));
519 let h = hash6(&fp_seed);
520 format!("{pkg_name}_{model_short}_{ts}_{h}")
521 }
522 };
523 validate_name(&card_id, "card_id")?;
524 obj.insert("card_id".to_string(), json!(card_id.clone()));
525
526 let toml_val = json_to_toml(input)?;
527 let text = toml::to_string_pretty(&toml_val)
528 .map_err(|e| format!("Failed to serialize card TOML: {e}"))?;
529 let path = store
530 .write_new_card(&pkg_name, &card_id, &text)
531 .map_err(|e| {
532 tracing::error!(
533 target: "alc.card",
534 pkg = %pkg_name,
535 card_id = %card_id,
536 error = %e,
537 "write_new_card failed"
538 );
539 e
540 })?;
541
542 publish(CardEvent::Created {
543 pkg: pkg_name.clone(),
544 card_id: card_id.clone(),
545 toml_text: text,
546 });
547
548 Ok((card_id, path))
549}
550
551pub fn get_with_store(store: &dyn CardStore, card_id: &str) -> Result<Option<Json>, String> {
554 let text = match store.read_card_text(card_id)? {
555 Some(t) => t,
556 None => return Ok(None),
557 };
558 let val: toml::Value =
559 toml::from_str(&text).map_err(|e| format!("Failed to parse card '{card_id}': {e}"))?;
560 Ok(Some(toml_to_json(val)))
561}
562
563#[derive(Debug, Clone)]
565pub struct Summary {
566 pub card_id: String,
567 pub pkg: String,
568 pub created_at: Option<String>,
569 pub model: Option<String>,
570 pub scenario: Option<String>,
571 pub pass_rate: Option<f64>,
572}
573
574impl Summary {
575 fn to_json(&self) -> Json {
576 let mut m = serde_json::Map::new();
577 m.insert("card_id".into(), json!(self.card_id));
578 m.insert("pkg".into(), json!(self.pkg));
579 if let Some(v) = &self.created_at {
580 m.insert("created_at".into(), json!(v));
581 }
582 if let Some(v) = &self.model {
583 m.insert("model".into(), json!(v));
584 }
585 if let Some(v) = &self.scenario {
586 m.insert("scenario".into(), json!(v));
587 }
588 if let Some(v) = self.pass_rate {
589 m.insert("pass_rate".into(), json!(v));
590 }
591 Json::Object(m)
592 }
593}
594
595fn summarize(store: &dyn CardStore, locator: &std::path::Path, pkg: &str) -> Option<Summary> {
596 let text = store.read_locator_text(locator).ok().flatten()?;
597 let val: toml::Value = toml::from_str(&text).ok()?;
598 let card_id = val
599 .get("card_id")
600 .and_then(|v| v.as_str())
601 .or_else(|| locator.file_stem().and_then(|s| s.to_str()))?
602 .to_string();
603 let created_at = val
604 .get("created_at")
605 .and_then(|v| v.as_str())
606 .map(String::from);
607 let model = val
608 .get("model")
609 .and_then(|m| m.get("id"))
610 .and_then(|v| v.as_str())
611 .map(String::from);
612 let scenario = val
613 .get("scenario")
614 .and_then(|s| s.get("name"))
615 .and_then(|v| v.as_str())
616 .map(String::from);
617 let pass_rate = val
618 .get("stats")
619 .and_then(|s| s.get("pass_rate"))
620 .and_then(|v| v.as_float());
621 Some(Summary {
622 card_id,
623 pkg: pkg.to_string(),
624 created_at,
625 model,
626 scenario,
627 pass_rate,
628 })
629}
630
631pub fn list_with_store(
633 store: &dyn CardStore,
634 pkg_filter: Option<&str>,
635) -> Result<Vec<Summary>, String> {
636 let locators = store.list_card_locators(pkg_filter)?;
637 let mut out = Vec::with_capacity(locators.len());
638 for (pkg, loc) in &locators {
639 if let Some(s) = summarize(store, loc, pkg) {
640 out.push(s);
641 }
642 }
643
644 out.sort_by(|a, b| {
648 b.created_at
649 .cmp(&a.created_at)
650 .then_with(|| b.card_id.cmp(&a.card_id))
651 });
652 Ok(out)
653}
654
655pub fn summaries_to_json(rows: &[Summary]) -> Json {
656 Json::Array(rows.iter().map(|s| s.to_json()).collect())
657}
658
659pub fn append_with_store(
672 store: &dyn CardStore,
673 card_id: &str,
674 fields: Json,
675) -> Result<Json, String> {
676 let text = store
677 .read_card_text(card_id)?
678 .ok_or_else(|| format!("alc.card.append: card '{card_id}' not found"))?;
679 let fields_obj = match fields {
680 Json::Object(m) => m,
681 _ => return Err("alc.card.append: fields must be a table".into()),
682 };
683
684 let existing: toml::Value =
685 toml::from_str(&text).map_err(|e| format!("Failed to parse card '{card_id}': {e}"))?;
686 let mut existing_json = toml_to_json(existing);
687 let existing_obj = existing_json
688 .as_object_mut()
689 .ok_or_else(|| format!("Card '{card_id}' is not a table"))?;
690
691 for (k, v) in fields_obj {
692 if existing_obj.contains_key(&k) {
693 return Err(format!(
694 "alc.card.append: key '{k}' already set on card '{card_id}' (immutable)"
695 ));
696 }
697 if !v.is_null() {
698 existing_obj.insert(k, v);
699 }
700 }
701
702 let toml_val = json_to_toml(existing_json.clone())?;
703 let text = toml::to_string_pretty(&toml_val)
704 .map_err(|e| format!("Failed to serialize card TOML: {e}"))?;
705 store.overwrite_card(card_id, &text)?;
706
707 publish(CardEvent::Appended {
708 card_id: card_id.to_string(),
709 toml_text: text,
710 });
711
712 Ok(existing_json)
713}
714
715#[derive(Debug, Clone)]
716pub struct Alias {
717 pub name: String,
718 pub card_id: String,
719 pub pkg: Option<String>,
720 pub set_at: String,
721 pub note: Option<String>,
722}
723
724impl Alias {
725 fn to_json(&self) -> Json {
726 let mut m = serde_json::Map::new();
727 m.insert("name".into(), json!(self.name));
728 m.insert("card_id".into(), json!(self.card_id));
729 if let Some(p) = &self.pkg {
730 m.insert("pkg".into(), json!(p));
731 }
732 m.insert("set_at".into(), json!(self.set_at));
733 if let Some(n) = &self.note {
734 m.insert("note".into(), json!(n));
735 }
736 Json::Object(m)
737 }
738}
739
740pub fn alias_set_with_store(
746 store: &dyn CardStore,
747 name: &str,
748 card_id: &str,
749 pkg: Option<&str>,
750 note: Option<&str>,
751) -> Result<Alias, String> {
752 validate_name(name, "alias")?;
753 if store.find_card_locator(card_id)?.is_none() {
754 return Err(format!("alc.card.alias_set: card '{card_id}' not found"));
755 }
756 let mut aliases = store.read_aliases()?;
757 aliases.retain(|a| a.name != name);
758 let entry = Alias {
759 name: name.to_string(),
760 card_id: card_id.to_string(),
761 pkg: pkg.map(String::from),
762 set_at: now_rfc3339(),
763 note: note.map(String::from),
764 };
765 aliases.push(entry.clone());
766 store.write_aliases(&aliases).map_err(|e| {
767 tracing::error!(
768 target: "alc.card",
769 name = %name,
770 card_id = %card_id,
771 error = %e,
772 "write_aliases failed"
773 );
774 e
775 })?;
776
777 match serialize_aliases_toml(&aliases) {
783 Ok(text) => publish(CardEvent::AliasesWritten { toml_text: text }),
784 Err(e) => tracing::warn!(error = %e, "alias_set: failed to serialize aliases for publish"),
785 }
786
787 Ok(entry)
788}
789
790fn serialize_aliases_toml(aliases: &[Alias]) -> Result<String, String> {
794 let mut arr = Vec::with_capacity(aliases.len());
795 for a in aliases {
796 let mut t = toml::map::Map::new();
797 t.insert("name".into(), toml::Value::String(a.name.clone()));
798 t.insert("card_id".into(), toml::Value::String(a.card_id.clone()));
799 if let Some(p) = &a.pkg {
800 t.insert("pkg".into(), toml::Value::String(p.clone()));
801 }
802 t.insert("set_at".into(), toml::Value::String(a.set_at.clone()));
803 if let Some(n) = &a.note {
804 t.insert("note".into(), toml::Value::String(n.clone()));
805 }
806 arr.push(toml::Value::Table(t));
807 }
808 let mut root = toml::map::Map::new();
809 root.insert("alias".into(), toml::Value::Array(arr));
810 toml::to_string_pretty(&toml::Value::Table(root))
811 .map_err(|e| format!("Failed to serialize aliases: {e}"))
812}
813
814pub fn get_by_alias_with_store(store: &dyn CardStore, name: &str) -> Result<Option<Json>, String> {
821 validate_name(name, "alias")?;
822 let aliases = store.read_aliases()?;
823 let Some(alias) = aliases.into_iter().find(|a| a.name == name) else {
824 return Ok(None);
825 };
826 match get_with_store(store, &alias.card_id)? {
827 Some(card) => Ok(Some(card)),
828 None => Err(format!(
829 "alc.card.get_by_alias: alias '{name}' points at missing card '{}'",
830 alias.card_id
831 )),
832 }
833}
834
835pub fn alias_list_with_store(
837 store: &dyn CardStore,
838 pkg_filter: Option<&str>,
839) -> Result<Vec<Alias>, String> {
840 let mut aliases = store.read_aliases()?;
841 if let Some(p) = pkg_filter {
842 aliases.retain(|a| a.pkg.as_deref() == Some(p));
843 }
844 Ok(aliases)
845}
846
847pub fn aliases_to_json(rows: &[Alias]) -> Json {
848 Json::Array(rows.iter().map(|a| a.to_json()).collect())
849}
850
851#[derive(Debug, Clone, PartialEq)]
885pub enum CmpOp {
886 Eq,
887 Ne,
888 Lt,
889 Lte,
890 Gt,
891 Gte,
892 In,
893 Nin,
894 Exists,
895 Contains,
896 StartsWith,
897}
898
899impl CmpOp {
900 fn from_key(k: &str) -> Option<Self> {
901 Some(match k {
902 "eq" => Self::Eq,
903 "ne" => Self::Ne,
904 "lt" => Self::Lt,
905 "lte" => Self::Lte,
906 "gt" => Self::Gt,
907 "gte" => Self::Gte,
908 "in" => Self::In,
909 "nin" => Self::Nin,
910 "exists" => Self::Exists,
911 "contains" => Self::Contains,
912 "starts_with" => Self::StartsWith,
913 _ => return None,
914 })
915 }
916}
917
918#[derive(Debug, Clone)]
921pub struct Comparison {
922 pub path: Vec<String>,
923 pub op: CmpOp,
924 pub value: Json,
925}
926
927#[derive(Debug, Clone)]
929pub enum Predicate {
930 And(Vec<Predicate>),
931 Or(Vec<Predicate>),
932 Not(Box<Predicate>),
933 Cmp(Comparison),
934}
935
936fn is_operator_object(obj: &serde_json::Map<String, Json>) -> bool {
939 if obj.is_empty() {
940 return false;
941 }
942 obj.keys().all(|k| CmpOp::from_key(k).is_some())
943}
944
945pub fn parse_where(value: &Json) -> Result<Predicate, String> {
950 parse_predicate(value, &[])
951}
952
953fn parse_predicate(value: &Json, prefix: &[String]) -> Result<Predicate, String> {
954 let obj = value
955 .as_object()
956 .ok_or_else(|| "where clause must be a table".to_string())?;
957
958 let mut clauses: Vec<Predicate> = Vec::new();
959
960 for (key, val) in obj {
961 match key.as_str() {
962 "_and" => {
963 let arr = val
964 .as_array()
965 .ok_or_else(|| "_and must be an array of sub-predicates".to_string())?;
966 let mut subs = Vec::with_capacity(arr.len());
967 for sub in arr {
968 subs.push(parse_predicate(sub, prefix)?);
969 }
970 clauses.push(Predicate::And(subs));
971 }
972 "_or" => {
973 let arr = val
974 .as_array()
975 .ok_or_else(|| "_or must be an array of sub-predicates".to_string())?;
976 let mut subs = Vec::with_capacity(arr.len());
977 for sub in arr {
978 subs.push(parse_predicate(sub, prefix)?);
979 }
980 clauses.push(Predicate::Or(subs));
981 }
982 "_not" => {
983 clauses.push(Predicate::Not(Box::new(parse_predicate(val, prefix)?)));
984 }
985 _ => {
986 let mut new_path = prefix.to_vec();
988 new_path.push(key.clone());
989
990 match val {
991 Json::Object(m) if is_operator_object(m) => {
992 for (op_key, op_val) in m {
994 let op = CmpOp::from_key(op_key).expect("validated above");
995 clauses.push(Predicate::Cmp(Comparison {
996 path: new_path.clone(),
997 op,
998 value: op_val.clone(),
999 }));
1000 }
1001 }
1002 Json::Object(_) => {
1003 clauses.push(parse_predicate(val, &new_path)?);
1005 }
1006 _ => {
1007 clauses.push(Predicate::Cmp(Comparison {
1009 path: new_path,
1010 op: CmpOp::Eq,
1011 value: val.clone(),
1012 }));
1013 }
1014 }
1015 }
1016 }
1017 }
1018
1019 if clauses.len() == 1 {
1020 Ok(clauses.remove(0))
1021 } else {
1022 Ok(Predicate::And(clauses))
1023 }
1024}
1025
1026fn fetch_path<'a>(card: &'a Json, path: &[String]) -> Option<&'a Json> {
1028 let mut node = card;
1029 for key in path {
1030 let obj = node.as_object()?;
1031 node = obj.get(key)?;
1032 }
1033 Some(node)
1034}
1035
1036fn json_cmp(a: &Json, b: &Json) -> Option<std::cmp::Ordering> {
1039 match (a, b) {
1040 (Json::Number(x), Json::Number(y)) => {
1041 let xf = x.as_f64()?;
1042 let yf = y.as_f64()?;
1043 xf.partial_cmp(&yf)
1044 }
1045 (Json::String(x), Json::String(y)) => Some(x.cmp(y)),
1046 (Json::Bool(x), Json::Bool(y)) => Some(x.cmp(y)),
1047 _ => None,
1048 }
1049}
1050
1051fn json_eq(a: &Json, b: &Json) -> bool {
1052 match (a, b) {
1053 (Json::Number(x), Json::Number(y)) => match (x.as_f64(), y.as_f64()) {
1054 (Some(xf), Some(yf)) => xf == yf,
1055 _ => a == b,
1056 },
1057 _ => a == b,
1058 }
1059}
1060
1061fn eval_cmp(cmp: &Comparison, card: &Json) -> bool {
1062 let actual = fetch_path(card, &cmp.path);
1063 let exists = actual.is_some();
1064
1065 match cmp.op {
1066 CmpOp::Exists => {
1067 let want = cmp.value.as_bool().unwrap_or(true);
1068 exists == want
1069 }
1070 CmpOp::Ne => match actual {
1071 None => true,
1072 Some(v) => !json_eq(v, &cmp.value),
1073 },
1074 CmpOp::Nin => match actual {
1075 None => true,
1076 Some(v) => match cmp.value.as_array() {
1077 Some(arr) => !arr.iter().any(|e| json_eq(e, v)),
1078 None => false,
1079 },
1080 },
1081 CmpOp::Eq => actual.is_some_and(|v| json_eq(v, &cmp.value)),
1082 CmpOp::In => actual.is_some_and(|v| match cmp.value.as_array() {
1083 Some(arr) => arr.iter().any(|e| json_eq(e, v)),
1084 None => false,
1085 }),
1086 CmpOp::Lt | CmpOp::Lte | CmpOp::Gt | CmpOp::Gte => {
1087 let Some(v) = actual else { return false };
1088 let Some(ord) = json_cmp(v, &cmp.value) else {
1089 return false;
1090 };
1091 use std::cmp::Ordering::{Equal, Greater, Less};
1092 matches!(
1093 (&cmp.op, ord),
1094 (CmpOp::Lt, Less)
1095 | (CmpOp::Lte, Less | Equal)
1096 | (CmpOp::Gt, Greater)
1097 | (CmpOp::Gte, Greater | Equal)
1098 )
1099 }
1100 CmpOp::Contains => {
1101 let Some(Json::String(haystack)) = actual else {
1102 return false;
1103 };
1104 let Some(needle) = cmp.value.as_str() else {
1105 return false;
1106 };
1107 haystack.contains(needle)
1108 }
1109 CmpOp::StartsWith => {
1110 let Some(Json::String(haystack)) = actual else {
1111 return false;
1112 };
1113 let Some(needle) = cmp.value.as_str() else {
1114 return false;
1115 };
1116 haystack.starts_with(needle)
1117 }
1118 }
1119}
1120
1121pub fn eval_predicate(pred: &Predicate, card: &Json) -> bool {
1123 match pred {
1124 Predicate::And(subs) => subs.iter().all(|p| eval_predicate(p, card)),
1125 Predicate::Or(subs) => subs.iter().any(|p| eval_predicate(p, card)),
1126 Predicate::Not(sub) => !eval_predicate(sub, card),
1127 Predicate::Cmp(c) => eval_cmp(c, card),
1128 }
1129}
1130
1131#[derive(Debug, Clone)]
1137pub struct OrderKey {
1138 pub path: Vec<String>,
1139 pub desc: bool,
1140}
1141
1142impl OrderKey {
1143 fn parse(raw: &str) -> Result<Self, String> {
1144 if raw.is_empty() {
1145 return Err("order_by key must not be empty".into());
1146 }
1147 let (desc, rest) = if let Some(r) = raw.strip_prefix('-') {
1148 (true, r)
1149 } else {
1150 (false, raw)
1151 };
1152 let path: Vec<String> = rest.split('.').map(|s| s.to_string()).collect();
1153 if path.iter().any(|p| p.is_empty()) {
1154 return Err(format!("invalid order_by key: '{raw}'"));
1155 }
1156 Ok(Self { path, desc })
1157 }
1158}
1159
1160pub fn parse_order_by(value: &Json) -> Result<Vec<OrderKey>, String> {
1164 match value {
1165 Json::String(s) => Ok(vec![OrderKey::parse(s)?]),
1166 Json::Array(arr) => {
1167 let mut out = Vec::with_capacity(arr.len());
1168 for v in arr {
1169 let s = v
1170 .as_str()
1171 .ok_or_else(|| "order_by array must contain strings".to_string())?;
1172 out.push(OrderKey::parse(s)?);
1173 }
1174 Ok(out)
1175 }
1176 _ => Err("order_by must be a string or array of strings".into()),
1177 }
1178}
1179
1180#[derive(Debug, Default, Clone)]
1182pub struct FindQuery {
1183 pub pkg: Option<String>,
1185 pub where_: Option<Predicate>,
1187 pub order_by: Vec<OrderKey>,
1189 pub limit: Option<usize>,
1190 pub offset: Option<usize>,
1191}
1192
1193#[derive(Debug, Clone)]
1199struct CardRow {
1200 full: Json,
1201 summary: Summary,
1202}
1203
1204fn load_full(store: &dyn CardStore, locator: &std::path::Path, pkg: &str) -> Option<CardRow> {
1207 let text = store.read_locator_text(locator).ok().flatten()?;
1208 let val: toml::Value = toml::from_str(&text).ok()?;
1209 let json = toml_to_json(val);
1210
1211 let card_id = json
1212 .get("card_id")
1213 .and_then(|v| v.as_str())
1214 .or_else(|| locator.file_stem().and_then(|s| s.to_str()))?
1215 .to_string();
1216 let created_at = json
1217 .get("created_at")
1218 .and_then(|v| v.as_str())
1219 .map(String::from);
1220 let model = json
1221 .get("model")
1222 .and_then(|m| m.get("id"))
1223 .and_then(|v| v.as_str())
1224 .map(String::from);
1225 let scenario = json
1226 .get("scenario")
1227 .and_then(|s| s.get("name"))
1228 .and_then(|v| v.as_str())
1229 .map(String::from);
1230 let pass_rate = json
1231 .get("stats")
1232 .and_then(|s| s.get("pass_rate"))
1233 .and_then(|v| v.as_f64());
1234
1235 Some(CardRow {
1236 full: json,
1237 summary: Summary {
1238 card_id,
1239 pkg: pkg.to_string(),
1240 created_at,
1241 model,
1242 scenario,
1243 pass_rate,
1244 },
1245 })
1246}
1247
1248fn order_cards(a: &CardRow, b: &CardRow, keys: &[OrderKey]) -> std::cmp::Ordering {
1250 use std::cmp::Ordering;
1251 for k in keys {
1252 let va = fetch_path(&a.full, &k.path);
1253 let vb = fetch_path(&b.full, &k.path);
1254 let ord = match (va, vb) {
1255 (None, None) => Ordering::Equal,
1256 (None, Some(_)) => Ordering::Greater, (Some(_), None) => Ordering::Less,
1258 (Some(x), Some(y)) => json_cmp(x, y).unwrap_or(Ordering::Equal),
1259 };
1260 let ord = if k.desc { ord.reverse() } else { ord };
1261 if ord != Ordering::Equal {
1262 return ord;
1263 }
1264 }
1265 Ordering::Equal
1266}
1267
1268const SUMMARY_SORT_FIELDS: &[&str] = &[
1270 "card_id",
1271 "created_at",
1272 "stats.pass_rate",
1273 "scenario.name",
1274 "model.id",
1275];
1276
1277fn is_lightweight_query(q: &FindQuery) -> bool {
1280 q.where_.is_none()
1281 && q.order_by
1282 .iter()
1283 .all(|k| SUMMARY_SORT_FIELDS.contains(&k.path.join(".").as_str()))
1284}
1285
1286fn order_summaries(a: &Summary, b: &Summary, keys: &[OrderKey]) -> std::cmp::Ordering {
1288 use std::cmp::Ordering;
1289 for k in keys {
1290 let key_str = k.path.join(".");
1291 let ord = match key_str.as_str() {
1292 "card_id" => a.card_id.cmp(&b.card_id),
1293 "created_at" => a.created_at.cmp(&b.created_at),
1294 "stats.pass_rate" => match (a.pass_rate, b.pass_rate) {
1295 (None, None) => Ordering::Equal,
1296 (None, Some(_)) => Ordering::Greater,
1297 (Some(_), None) => Ordering::Less,
1298 (Some(x), Some(y)) => x.partial_cmp(&y).unwrap_or(Ordering::Equal),
1299 },
1300 "scenario.name" => a.scenario.cmp(&b.scenario),
1301 "model.id" => a.model.cmp(&b.model),
1302 _ => Ordering::Equal,
1303 };
1304 let ord = if k.desc { ord.reverse() } else { ord };
1305 if ord != Ordering::Equal {
1306 return ord;
1307 }
1308 }
1309 Ordering::Equal
1310}
1311
1312pub fn find_with_store(store: &dyn CardStore, q: FindQuery) -> Result<Vec<Summary>, String> {
1319 if is_lightweight_query(&q) {
1321 let mut rows = list_with_store(store, q.pkg.as_deref())?;
1322 if q.order_by.is_empty() {
1323 rows.sort_by(|a, b| {
1324 b.created_at
1325 .cmp(&a.created_at)
1326 .then_with(|| b.card_id.cmp(&a.card_id))
1327 });
1328 } else {
1329 rows.sort_by(|a, b| order_summaries(a, b, &q.order_by));
1330 }
1331 let out: Vec<Summary> = rows
1332 .into_iter()
1333 .skip(q.offset.unwrap_or(0))
1334 .take(q.limit.unwrap_or(usize::MAX))
1335 .collect();
1336 return Ok(out);
1337 }
1338
1339 let all_rows = scan_cards(store, q.pkg.as_deref())?;
1341
1342 let mut rows: Vec<CardRow> = if let Some(pred) = &q.where_ {
1344 all_rows
1345 .into_iter()
1346 .filter(|row| eval_predicate(pred, &row.full))
1347 .collect()
1348 } else {
1349 all_rows
1350 };
1351
1352 if q.order_by.is_empty() {
1354 rows.sort_by(|a, b| {
1355 b.summary
1356 .created_at
1357 .cmp(&a.summary.created_at)
1358 .then_with(|| b.summary.card_id.cmp(&a.summary.card_id))
1359 });
1360 } else {
1361 rows.sort_by(|a, b| order_cards(a, b, &q.order_by));
1362 }
1363
1364 let out: Vec<Summary> = rows
1366 .into_iter()
1367 .skip(q.offset.unwrap_or(0))
1368 .take(q.limit.unwrap_or(usize::MAX))
1369 .map(|r| r.summary)
1370 .collect();
1371
1372 Ok(out)
1373}
1374
1375#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
1391pub enum LineageDirection {
1392 #[default]
1393 Up,
1394 Down,
1395 Both,
1396}
1397
1398impl LineageDirection {
1399 pub fn parse(s: &str) -> Result<Self, String> {
1400 match s {
1401 "up" => Ok(Self::Up),
1402 "down" => Ok(Self::Down),
1403 "both" => Ok(Self::Both),
1404 other => Err(format!(
1405 "direction must be 'up', 'down', or 'both' (got '{other}')"
1406 )),
1407 }
1408 }
1409}
1410
1411#[derive(Debug, Clone, Default)]
1413pub struct LineageQuery {
1414 pub card_id: String,
1415 pub direction: LineageDirection,
1416 pub depth: Option<usize>,
1418 pub include_stats: bool,
1420 pub relation_filter: Option<Vec<String>>,
1423}
1424
1425#[derive(Debug, Clone)]
1430pub struct LineageNode {
1431 pub card_id: String,
1432 pub pkg: String,
1433 pub prior_card_id: Option<String>,
1434 pub prior_relation: Option<String>,
1435 pub depth: i32,
1436 pub stats: Option<Json>,
1437}
1438
1439#[derive(Debug, Clone)]
1441pub struct LineageEdge {
1442 pub from: String,
1443 pub to: String,
1444 pub relation: Option<String>,
1445}
1446
1447#[derive(Debug, Clone)]
1449pub struct LineageResult {
1450 pub root: String,
1451 pub nodes: Vec<LineageNode>,
1452 pub edges: Vec<LineageEdge>,
1453 pub truncated: bool,
1454}
1455
1456const DEFAULT_LINEAGE_DEPTH: usize = 10;
1457
1458fn lineage_fields(card: &Json) -> (Option<String>, Option<String>) {
1461 let meta = card.get("metadata");
1462 let prior_card_id = meta
1463 .and_then(|m| m.get("prior_card_id"))
1464 .and_then(|v| v.as_str())
1465 .map(String::from);
1466 let prior_relation = meta
1467 .and_then(|m| m.get("prior_relation"))
1468 .and_then(|v| v.as_str())
1469 .map(String::from);
1470 (prior_card_id, prior_relation)
1471}
1472
1473fn make_node(row: &CardRow, depth: i32, include_stats: bool) -> LineageNode {
1475 let (prior_card_id, prior_relation) = lineage_fields(&row.full);
1476 let stats = if include_stats {
1477 row.full.get("stats").cloned()
1478 } else {
1479 None
1480 };
1481 LineageNode {
1482 card_id: row.summary.card_id.clone(),
1483 pkg: row.summary.pkg.clone(),
1484 prior_card_id,
1485 prior_relation,
1486 depth,
1487 stats,
1488 }
1489}
1490
1491fn relation_passes(filter: &Option<Vec<String>>, relation: &Option<String>) -> bool {
1494 match filter {
1495 None => true,
1496 Some(allowed) => match relation {
1497 Some(r) => allowed.iter().any(|a| a == r),
1498 None => false,
1499 },
1500 }
1501}
1502
1503struct CardIndex {
1505 cards: std::collections::HashMap<String, CardRow>,
1507 children: std::collections::HashMap<String, Vec<String>>,
1509}
1510
1511fn load_card_index(store: &dyn CardStore) -> Result<CardIndex, String> {
1515 let rows = scan_cards(store, None)?;
1516
1517 let mut cards = std::collections::HashMap::with_capacity(rows.len());
1518 let mut children: std::collections::HashMap<String, Vec<String>> =
1519 std::collections::HashMap::new();
1520
1521 for row in rows {
1522 let id = row.summary.card_id.clone();
1523 let (prior_id, _) = lineage_fields(&row.full);
1524 if let Some(parent) = prior_id {
1525 children.entry(parent).or_default().push(id.clone());
1526 }
1527 cards.insert(id, row);
1528 }
1529 Ok(CardIndex { cards, children })
1530}
1531
1532fn scan_cards(store: &dyn CardStore, pkg_filter: Option<&str>) -> Result<Vec<CardRow>, String> {
1536 let locators = store.list_card_locators(pkg_filter)?;
1537 let mut rows = Vec::with_capacity(locators.len());
1538 for (pkg, loc) in &locators {
1539 if let Some(row) = load_full(store, loc, pkg) {
1540 rows.push(row);
1541 }
1542 }
1543 Ok(rows)
1544}
1545
1546struct LineageCtx<'a> {
1548 index: &'a CardIndex,
1549 relation_filter: &'a Option<Vec<String>>,
1550 include_stats: bool,
1551 max_depth: usize,
1552}
1553
1554struct LineageAccum {
1556 nodes: Vec<LineageNode>,
1557 edges: Vec<LineageEdge>,
1558 visited: std::collections::HashSet<String>,
1559 truncated: bool,
1560}
1561
1562fn walk_up(start_id: &str, ctx: &LineageCtx<'_>, acc: &mut LineageAccum) {
1564 let mut cur = start_id.to_string();
1565 for step in 1..=ctx.max_depth {
1566 let Some(row) = ctx.index.cards.get(&cur) else {
1567 return;
1568 };
1569 let (prior_id, prior_rel) = lineage_fields(&row.full);
1570 let Some(prior_id) = prior_id else {
1571 return;
1572 };
1573 if !relation_passes(ctx.relation_filter, &prior_rel) {
1574 return;
1575 }
1576 if acc.visited.contains(&prior_id) {
1577 return;
1578 }
1579 let Some(parent) = ctx.index.cards.get(&prior_id) else {
1580 return;
1581 };
1582 acc.nodes
1583 .push(make_node(parent, -(step as i32), ctx.include_stats));
1584 acc.edges.push(LineageEdge {
1585 from: row.summary.card_id.clone(),
1586 to: parent.summary.card_id.clone(),
1587 relation: prior_rel,
1588 });
1589 acc.visited.insert(prior_id.clone());
1590 cur = prior_id;
1591 }
1592 if let Some(row) = ctx.index.cards.get(&cur) {
1594 let (prior_id, _) = lineage_fields(&row.full);
1595 if prior_id
1596 .as_ref()
1597 .is_some_and(|p| ctx.index.cards.contains_key(p) && !acc.visited.contains(p))
1598 {
1599 acc.truncated = true;
1600 }
1601 }
1602}
1603
1604fn walk_down(start_id: &str, ctx: &LineageCtx<'_>, acc: &mut LineageAccum) {
1607 let mut frontier: Vec<String> = vec![start_id.to_string()];
1608
1609 for depth in 1..=ctx.max_depth {
1610 let mut next_frontier: Vec<String> = Vec::new();
1611 for parent_id in &frontier {
1612 let children = match ctx.index.children.get(parent_id) {
1613 Some(c) => c,
1614 None => continue,
1615 };
1616 for child_id in children {
1617 if acc.visited.contains(child_id) {
1618 continue;
1619 }
1620 let Some(child) = ctx.index.cards.get(child_id) else {
1621 continue;
1622 };
1623 let (_, prior_rel) = lineage_fields(&child.full);
1624 if !relation_passes(ctx.relation_filter, &prior_rel) {
1625 continue;
1626 }
1627 acc.nodes
1628 .push(make_node(child, depth as i32, ctx.include_stats));
1629 acc.edges.push(LineageEdge {
1630 from: child.summary.card_id.clone(),
1631 to: parent_id.clone(),
1632 relation: prior_rel,
1633 });
1634 acc.visited.insert(child_id.clone());
1635 next_frontier.push(child_id.clone());
1636 }
1637 }
1638 if next_frontier.is_empty() {
1639 return;
1640 }
1641 frontier = next_frontier;
1642 }
1643 for parent_id in &frontier {
1646 let children = match ctx.index.children.get(parent_id) {
1647 Some(c) => c,
1648 None => continue,
1649 };
1650 for child_id in children {
1651 if acc.visited.contains(child_id) {
1652 continue;
1653 }
1654 let Some(child) = ctx.index.cards.get(child_id) else {
1655 continue;
1656 };
1657 let (_, prior_rel) = lineage_fields(&child.full);
1658 if relation_passes(ctx.relation_filter, &prior_rel) {
1659 acc.truncated = true;
1660 return;
1661 }
1662 }
1663 }
1664}
1665
1666pub fn lineage_with_store(
1668 store: &dyn CardStore,
1669 q: LineageQuery,
1670) -> Result<Option<LineageResult>, String> {
1671 let index = load_card_index(store)?;
1672 let Some(root_row) = index.cards.get(&q.card_id) else {
1673 return Ok(None);
1674 };
1675
1676 let ctx = LineageCtx {
1677 index: &index,
1678 relation_filter: &q.relation_filter,
1679 include_stats: q.include_stats,
1680 max_depth: q.depth.unwrap_or(DEFAULT_LINEAGE_DEPTH),
1681 };
1682 let mut acc = LineageAccum {
1683 nodes: Vec::new(),
1684 edges: Vec::new(),
1685 visited: std::collections::HashSet::new(),
1686 truncated: false,
1687 };
1688
1689 acc.nodes.push(make_node(root_row, 0, q.include_stats));
1690 acc.visited.insert(q.card_id.clone());
1691
1692 if matches!(q.direction, LineageDirection::Up | LineageDirection::Both) {
1693 walk_up(&q.card_id, &ctx, &mut acc);
1694 }
1695 if matches!(q.direction, LineageDirection::Down | LineageDirection::Both) {
1696 walk_down(&q.card_id, &ctx, &mut acc);
1697 }
1698
1699 Ok(Some(LineageResult {
1700 root: q.card_id,
1701 nodes: acc.nodes,
1702 edges: acc.edges,
1703 truncated: acc.truncated,
1704 }))
1705}
1706
1707pub fn lineage_to_json(r: &LineageResult) -> Json {
1709 let nodes: Vec<Json> = r
1710 .nodes
1711 .iter()
1712 .map(|n| {
1713 let mut m = serde_json::Map::new();
1714 m.insert("card_id".into(), json!(n.card_id));
1715 m.insert("pkg".into(), json!(n.pkg));
1716 m.insert("depth".into(), json!(n.depth));
1717 if let Some(p) = &n.prior_card_id {
1718 m.insert("prior_card_id".into(), json!(p));
1719 }
1720 if let Some(rel) = &n.prior_relation {
1721 m.insert("prior_relation".into(), json!(rel));
1722 }
1723 if let Some(s) = &n.stats {
1724 m.insert("stats".into(), s.clone());
1725 }
1726 Json::Object(m)
1727 })
1728 .collect();
1729 let edges: Vec<Json> = r
1730 .edges
1731 .iter()
1732 .map(|e| {
1733 let mut m = serde_json::Map::new();
1734 m.insert("from".into(), json!(e.from));
1735 m.insert("to".into(), json!(e.to));
1736 if let Some(rel) = &e.relation {
1737 m.insert("relation".into(), json!(rel));
1738 }
1739 Json::Object(m)
1740 })
1741 .collect();
1742 json!({
1743 "root": r.root,
1744 "nodes": nodes,
1745 "edges": edges,
1746 "truncated": r.truncated,
1747 })
1748}
1749
1750pub fn import_from_dir_with_store(
1770 store: &dyn CardStore,
1771 source_dir: &std::path::Path,
1772 pkg: &str,
1773) -> Result<(Vec<String>, Vec<String>), String> {
1774 let (imported, skipped) = store.import_from_dir(source_dir, pkg)?;
1775 for card_id in &imported {
1776 match store.read_card_text(card_id) {
1777 Ok(Some(toml_text)) => publish(CardEvent::Created {
1778 pkg: pkg.to_string(),
1779 card_id: card_id.clone(),
1780 toml_text,
1781 }),
1782 Ok(None) => {
1783 tracing::warn!(
1784 card_id = %card_id,
1785 "import_from_dir: read_card_text returned None after import; skipping publish"
1786 );
1787 }
1788 Err(e) => {
1789 tracing::warn!(
1790 card_id = %card_id,
1791 error = %e,
1792 "import_from_dir: read_card_text failed after import; skipping publish"
1793 );
1794 }
1795 }
1796 match store.read_samples_text(card_id) {
1798 Ok(Some(jsonl_text)) => publish(CardEvent::SamplesWritten {
1799 card_id: card_id.clone(),
1800 jsonl_text,
1801 }),
1802 Ok(None) => {}
1803 Err(e) => {
1804 tracing::warn!(
1805 card_id = %card_id,
1806 error = %e,
1807 "import_from_dir: read_samples_text failed after import; skipping publish"
1808 );
1809 }
1810 }
1811 }
1812 Ok((imported, skipped))
1813}
1814
1815pub fn write_samples_with_store(
1821 store: &dyn CardStore,
1822 card_id: &str,
1823 samples: Vec<Json>,
1824) -> Result<PathBuf, String> {
1825 if store.samples_exists(card_id).map_err(|e| {
1826 tracing::error!(
1827 target: "alc.card",
1828 card_id = %card_id,
1829 error = %e,
1830 "samples_exists failed"
1831 );
1832 e
1833 })? {
1834 let msg = format!(
1835 "alc.card.write_samples: samples already exist for card '{card_id}' (write-once)"
1836 );
1837 tracing::error!(
1838 target: "alc.card",
1839 card_id = %card_id,
1840 error = %msg,
1841 "write_samples_text failed (write-once conflict)"
1842 );
1843 return Err(msg);
1844 }
1845 let mut buf = String::new();
1846 for (idx, s) in samples.iter().enumerate() {
1847 let line = serde_json::to_string(s).map_err(|e| {
1848 format!("alc.card.write_samples: failed to serialize sample #{idx}: {e}")
1849 })?;
1850 buf.push_str(&line);
1851 buf.push('\n');
1852 }
1853 let path = store.write_samples_text(card_id, &buf).map_err(|e| {
1854 tracing::error!(
1855 target: "alc.card",
1856 card_id = %card_id,
1857 error = %e,
1858 "write_samples_text failed"
1859 );
1860 e
1861 })?;
1862
1863 publish(CardEvent::SamplesWritten {
1864 card_id: card_id.to_string(),
1865 jsonl_text: buf,
1866 });
1867
1868 Ok(path)
1869}
1870
1871#[derive(Debug, Default, Clone)]
1873pub struct SamplesQuery {
1874 pub offset: usize,
1876 pub limit: Option<usize>,
1878 pub where_: Option<Predicate>,
1881}
1882
1883pub fn read_samples_with_store(
1894 store: &dyn CardStore,
1895 card_id: &str,
1896 q: SamplesQuery,
1897) -> Result<Vec<Json>, String> {
1898 let text = match store.read_samples_text(card_id)? {
1899 Some(t) => t,
1900 None => return Ok(Vec::new()),
1901 };
1902 let mut matched: usize = 0;
1903 let mut out = Vec::new();
1904 for (i, line) in text.lines().enumerate() {
1905 if line.trim().is_empty() {
1906 continue;
1907 }
1908 let val: Json = serde_json::from_str(line)
1909 .map_err(|e| format!("Failed to parse sample line {i}: {e}"))?;
1910 if let Some(pred) = &q.where_ {
1911 if !eval_predicate(pred, &val) {
1912 continue;
1913 }
1914 }
1915 if matched < q.offset {
1916 matched += 1;
1917 continue;
1918 }
1919 if let Some(lim) = q.limit {
1920 if out.len() >= lim {
1921 break;
1922 }
1923 }
1924 matched += 1;
1925 out.push(val);
1926 }
1927 Ok(out)
1928}
1929
1930pub struct FileCardStore {
1944 root: PathBuf,
1945}
1946
1947impl FileCardStore {
1948 pub fn new(root: PathBuf) -> Self {
1950 Self { root }
1951 }
1952
1953 pub fn root(&self) -> &Path {
1955 &self.root
1956 }
1957
1958 pub fn create(&self, input: Json) -> Result<(String, PathBuf), String> {
1966 create_with_store(self, input)
1967 }
1968
1969 pub fn get(&self, card_id: &str) -> Result<Option<Json>, String> {
1970 get_with_store(self, card_id)
1971 }
1972
1973 pub fn list(&self, pkg_filter: Option<&str>) -> Result<Vec<Summary>, String> {
1974 list_with_store(self, pkg_filter)
1975 }
1976
1977 pub fn append(&self, card_id: &str, fields: Json) -> Result<Json, String> {
1978 append_with_store(self, card_id, fields)
1979 }
1980
1981 pub fn alias_set(
1982 &self,
1983 name: &str,
1984 card_id: &str,
1985 pkg: Option<&str>,
1986 note: Option<&str>,
1987 ) -> Result<Alias, String> {
1988 alias_set_with_store(self, name, card_id, pkg, note)
1989 }
1990
1991 pub fn alias_list(&self, pkg_filter: Option<&str>) -> Result<Vec<Alias>, String> {
1992 alias_list_with_store(self, pkg_filter)
1993 }
1994
1995 pub fn get_by_alias(&self, name: &str) -> Result<Option<Json>, String> {
1996 get_by_alias_with_store(self, name)
1997 }
1998
1999 pub fn find(&self, q: FindQuery) -> Result<Vec<Summary>, String> {
2000 find_with_store(self, q)
2001 }
2002
2003 pub fn write_samples(&self, card_id: &str, samples: Vec<Json>) -> Result<PathBuf, String> {
2004 write_samples_with_store(self, card_id, samples)
2005 }
2006
2007 pub fn read_samples(&self, card_id: &str, q: SamplesQuery) -> Result<Vec<Json>, String> {
2008 read_samples_with_store(self, card_id, q)
2009 }
2010
2011 pub fn lineage(&self, q: LineageQuery) -> Result<Option<LineageResult>, String> {
2012 lineage_with_store(self, q)
2013 }
2014
2015 pub fn card_sink_backfill(
2016 &self,
2017 sink: &str,
2018 dry_run: bool,
2019 ) -> Result<SinkBackfillReport, String> {
2020 card_sink_backfill_with_store(self, sink, dry_run)
2021 }
2022
2023 fn pkg_dir(&self, pkg: &str) -> Result<PathBuf, String> {
2027 validate_name(pkg, "pkg")?;
2028 let dir = self.root.join(pkg);
2029 if !dir.exists() {
2030 fs::create_dir_all(&dir).map_err(|e| format!("Failed to create pkg dir: {e}"))?;
2031 }
2032 Ok(dir)
2033 }
2034
2035 fn aliases_path(&self) -> PathBuf {
2037 self.root.join("_aliases.toml")
2038 }
2039
2040 fn samples_path(&self, card_id: &str) -> Result<PathBuf, String> {
2044 let card_path = self
2045 .find_card_locator(card_id)?
2046 .ok_or_else(|| format!("card '{card_id}' not found"))?;
2047 let dir = card_path
2048 .parent()
2049 .ok_or_else(|| format!("card '{card_id}' has no parent directory"))?;
2050 Ok(dir.join(format!("{card_id}.samples.jsonl")))
2051 }
2052}
2053
2054impl CardStore for FileCardStore {
2055 fn write_new_card(&self, pkg: &str, card_id: &str, toml_text: &str) -> Result<PathBuf, String> {
2056 let dir = self.pkg_dir(pkg)?;
2057 let path = dir.join(format!("{card_id}.toml"));
2058 if path.exists() {
2059 return Err(format!(
2060 "alc.card.create: card '{card_id}' already exists (immutable)"
2061 ));
2062 }
2063 let tmp = path.with_extension("toml.tmp");
2064 fs::write(&tmp, toml_text).map_err(|e| format!("Failed to write card tmp: {e}"))?;
2065 fs::rename(&tmp, &path).map_err(|e| format!("Failed to rename card file: {e}"))?;
2066 Ok(path)
2067 }
2068
2069 fn overwrite_card(&self, card_id: &str, toml_text: &str) -> Result<PathBuf, String> {
2070 let path = self
2071 .find_card_locator(card_id)?
2072 .ok_or_else(|| format!("alc.card.overwrite: card '{card_id}' not found"))?;
2073 let tmp = path.with_extension("toml.tmp");
2074 fs::write(&tmp, toml_text).map_err(|e| format!("Failed to write card tmp: {e}"))?;
2075 fs::rename(&tmp, &path).map_err(|e| format!("Failed to rename card file: {e}"))?;
2076 Ok(path)
2077 }
2078
2079 fn find_card_locator(&self, card_id: &str) -> Result<Option<PathBuf>, String> {
2080 validate_name(card_id, "card_id")?;
2081 if !self.root.exists() {
2082 return Ok(None);
2083 }
2084 let entries =
2085 fs::read_dir(&self.root).map_err(|e| format!("Failed to read cards dir: {e}"))?;
2086 for entry in entries.flatten() {
2087 let p = entry.path();
2088 if p.is_dir() {
2089 let candidate = p.join(format!("{card_id}.toml"));
2090 if candidate.exists() {
2091 return Ok(Some(candidate));
2092 }
2093 }
2094 }
2095 Ok(None)
2096 }
2097
2098 fn read_card_text(&self, card_id: &str) -> Result<Option<String>, String> {
2099 let Some(path) = self.find_card_locator(card_id)? else {
2100 return Ok(None);
2101 };
2102 let text = fs::read_to_string(&path)
2103 .map_err(|e| format!("Failed to read card '{card_id}': {e}"))?;
2104 Ok(Some(text))
2105 }
2106
2107 fn list_card_locators(
2108 &self,
2109 pkg_filter: Option<&str>,
2110 ) -> Result<Vec<(String, PathBuf)>, String> {
2111 if !self.root.exists() {
2112 return Ok(Vec::new());
2113 }
2114 let pkg_dirs: Vec<PathBuf> = if let Some(p) = pkg_filter {
2115 validate_name(p, "pkg")?;
2116 let d = self.root.join(p);
2117 if d.is_dir() {
2118 vec![d]
2119 } else {
2120 return Ok(Vec::new());
2121 }
2122 } else {
2123 fs::read_dir(&self.root)
2124 .map_err(|e| format!("Failed to read cards dir: {e}"))?
2125 .flatten()
2126 .map(|e| e.path())
2127 .filter(|p| p.is_dir())
2128 .collect()
2129 };
2130
2131 let mut out = Vec::new();
2132 for pdir in pkg_dirs {
2133 let pkg = pdir
2134 .file_name()
2135 .and_then(|s| s.to_str())
2136 .unwrap_or("")
2137 .to_string();
2138 let entries = match fs::read_dir(&pdir) {
2139 Ok(e) => e,
2140 Err(_) => continue,
2141 };
2142 for entry in entries.flatten() {
2143 let p = entry.path();
2144 if p.extension().and_then(|s| s.to_str()) != Some("toml") {
2145 continue;
2146 }
2147 out.push((pkg.clone(), p));
2148 }
2149 }
2150 Ok(out)
2151 }
2152
2153 fn read_locator_text(&self, locator: &Path) -> Result<Option<String>, String> {
2154 match fs::read_to_string(locator) {
2155 Ok(text) => Ok(Some(text)),
2156 Err(_) => Ok(None),
2157 }
2158 }
2159
2160 fn read_aliases(&self) -> Result<Vec<Alias>, String> {
2161 let path = self.aliases_path();
2162 if !path.exists() {
2163 return Ok(Vec::new());
2164 }
2165 let text =
2166 fs::read_to_string(&path).map_err(|e| format!("Failed to read aliases file: {e}"))?;
2167 let val: toml::Value =
2168 toml::from_str(&text).map_err(|e| format!("Failed to parse aliases file: {e}"))?;
2169 let arr = val
2170 .get("alias")
2171 .and_then(|v| v.as_array())
2172 .cloned()
2173 .unwrap_or_default();
2174 let mut out = Vec::with_capacity(arr.len());
2175 for entry in arr {
2176 let t = match entry {
2177 toml::Value::Table(t) => t,
2178 _ => continue,
2179 };
2180 let name = match t.get("name").and_then(|v| v.as_str()) {
2181 Some(s) => s.to_string(),
2182 None => continue,
2183 };
2184 let card_id = match t.get("card_id").and_then(|v| v.as_str()) {
2185 Some(s) => s.to_string(),
2186 None => continue,
2187 };
2188 out.push(Alias {
2189 name,
2190 card_id,
2191 pkg: t.get("pkg").and_then(|v| v.as_str()).map(String::from),
2192 set_at: t
2193 .get("set_at")
2194 .and_then(|v| v.as_str())
2195 .map(String::from)
2196 .unwrap_or_default(),
2197 note: t.get("note").and_then(|v| v.as_str()).map(String::from),
2198 });
2199 }
2200 Ok(out)
2201 }
2202
2203 fn write_aliases(&self, aliases: &[Alias]) -> Result<(), String> {
2204 if !self.root.exists() {
2207 fs::create_dir_all(&self.root)
2208 .map_err(|e| format!("Failed to create cards dir: {e}"))?;
2209 }
2210 let path = self.aliases_path();
2211 let mut arr = Vec::with_capacity(aliases.len());
2212 for a in aliases {
2213 let mut t = toml::map::Map::new();
2214 t.insert("name".into(), toml::Value::String(a.name.clone()));
2215 t.insert("card_id".into(), toml::Value::String(a.card_id.clone()));
2216 if let Some(p) = &a.pkg {
2217 t.insert("pkg".into(), toml::Value::String(p.clone()));
2218 }
2219 t.insert("set_at".into(), toml::Value::String(a.set_at.clone()));
2220 if let Some(n) = &a.note {
2221 t.insert("note".into(), toml::Value::String(n.clone()));
2222 }
2223 arr.push(toml::Value::Table(t));
2224 }
2225 let mut root = toml::map::Map::new();
2226 root.insert("alias".into(), toml::Value::Array(arr));
2227 let text = toml::to_string_pretty(&toml::Value::Table(root))
2228 .map_err(|e| format!("Failed to serialize aliases: {e}"))?;
2229 let tmp = path.with_extension("toml.tmp");
2230 fs::write(&tmp, &text).map_err(|e| format!("Failed to write aliases tmp: {e}"))?;
2231 fs::rename(&tmp, &path).map_err(|e| format!("Failed to rename aliases file: {e}"))?;
2232 Ok(())
2233 }
2234
2235 fn samples_exists(&self, card_id: &str) -> Result<bool, String> {
2236 let path = self.samples_path(card_id)?;
2237 Ok(path.exists())
2238 }
2239
2240 fn write_samples_text(&self, card_id: &str, jsonl_text: &str) -> Result<PathBuf, String> {
2241 let path = self.samples_path(card_id)?;
2242 if path.exists() {
2243 return Err(format!(
2244 "alc.card.write_samples: samples already exist for card '{card_id}' (write-once)"
2245 ));
2246 }
2247 let tmp = path.with_extension("jsonl.tmp");
2248 fs::write(&tmp, jsonl_text).map_err(|e| format!("Failed to write samples tmp: {e}"))?;
2249 fs::rename(&tmp, &path).map_err(|e| format!("Failed to rename samples file: {e}"))?;
2250 Ok(path)
2251 }
2252
2253 fn read_samples_text(&self, card_id: &str) -> Result<Option<String>, String> {
2254 let path = self.samples_path(card_id)?;
2255 if !path.exists() {
2256 return Ok(None);
2257 }
2258 let text =
2259 fs::read_to_string(&path).map_err(|e| format!("Failed to read samples file: {e}"))?;
2260 Ok(Some(text))
2261 }
2262
2263 fn import_from_dir(
2264 &self,
2265 source_dir: &Path,
2266 pkg: &str,
2267 ) -> Result<(Vec<String>, Vec<String>), String> {
2268 validate_name(pkg, "pkg")?;
2269 let dest = self.pkg_dir(pkg)?;
2270 let mut imported = Vec::new();
2271 let mut skipped = Vec::new();
2272
2273 let entries =
2274 fs::read_dir(source_dir).map_err(|e| format!("Failed to read card source dir: {e}"))?;
2275
2276 for entry in entries.flatten() {
2277 let path = entry.path();
2278 let fname = match path.file_name().and_then(|n| n.to_str()) {
2279 Some(n) => n.to_string(),
2280 None => continue,
2281 };
2282
2283 if !fname.ends_with(".toml") {
2284 continue;
2285 }
2286
2287 let card_id = fname.trim_end_matches(".toml");
2288 let dest_toml = dest.join(&fname);
2289
2290 if dest_toml.exists() {
2291 skipped.push(card_id.to_string());
2292 continue;
2293 }
2294
2295 let text = fs::read_to_string(&path)
2296 .map_err(|e| format!("Failed to read card file '{fname}': {e}"))?;
2297 let val: toml::Value = toml::from_str(&text)
2298 .map_err(|e| format!("Failed to parse card file '{fname}': {e}"))?;
2299 if val.get("schema_version").and_then(|v| v.as_str()) != Some(SCHEMA_VERSION) {
2300 continue;
2301 }
2302
2303 fs::copy(&path, &dest_toml)
2304 .map_err(|e| format!("Failed to copy card '{fname}': {e}"))?;
2305
2306 let samples_name = format!("{card_id}.samples.jsonl");
2307 let samples_src = source_dir.join(&samples_name);
2308 if samples_src.exists() {
2309 let samples_dest = dest.join(&samples_name);
2310 if !samples_dest.exists() {
2311 fs::copy(&samples_src, &samples_dest)
2312 .map_err(|e| format!("Failed to copy samples '{samples_name}': {e}"))?;
2313 }
2314 }
2315
2316 imported.push(card_id.to_string());
2317 }
2318
2319 Ok((imported, skipped))
2320 }
2321}
2322
2323#[derive(Debug, Clone)]
2341pub enum CardEvent {
2342 Created {
2344 pkg: String,
2345 card_id: String,
2346 toml_text: String,
2347 },
2348 Appended { card_id: String, toml_text: String },
2350 SamplesWritten { card_id: String, jsonl_text: String },
2352 AliasesWritten { toml_text: String },
2354}
2355
2356#[derive(Clone, Copy, Debug, Hash, Eq, PartialEq)]
2360pub enum CardEventKind {
2361 Created,
2362 Appended,
2363 SamplesWritten,
2364 AliasesWritten,
2365}
2366
2367impl CardEventKind {
2368 pub fn as_str(self) -> &'static str {
2370 match self {
2371 CardEventKind::Created => "created",
2372 CardEventKind::Appended => "appended",
2373 CardEventKind::SamplesWritten => "samples_written",
2374 CardEventKind::AliasesWritten => "aliases_written",
2375 }
2376 }
2377
2378 pub fn json_key(self) -> &'static str {
2382 match self {
2383 CardEventKind::Created => "created",
2384 CardEventKind::Appended => "appended",
2385 CardEventKind::SamplesWritten => "samples",
2386 CardEventKind::AliasesWritten => "aliases",
2387 }
2388 }
2389
2390 pub fn all() -> [CardEventKind; 4] {
2394 [
2395 CardEventKind::Created,
2396 CardEventKind::Appended,
2397 CardEventKind::SamplesWritten,
2398 CardEventKind::AliasesWritten,
2399 ]
2400 }
2401}
2402
2403impl Serialize for CardEventKind {
2404 fn serialize<S: Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
2405 s.serialize_str(self.json_key())
2406 }
2407}
2408
2409impl CardEvent {
2410 pub fn kind(&self) -> CardEventKind {
2412 match self {
2413 CardEvent::Created { .. } => CardEventKind::Created,
2414 CardEvent::Appended { .. } => CardEventKind::Appended,
2415 CardEvent::SamplesWritten { .. } => CardEventKind::SamplesWritten,
2416 CardEvent::AliasesWritten { .. } => CardEventKind::AliasesWritten,
2417 }
2418 }
2419}
2420
2421pub trait CardSubscriber: Send + Sync {
2429 fn on_event(&self, ev: &CardEvent) -> Result<(), String>;
2433
2434 fn describe(&self) -> String;
2438
2439 fn has_card(&self, _card_id: &str) -> Result<bool, String> {
2447 Ok(false)
2448 }
2449}
2450
2451#[derive(Debug, Clone, Serialize)]
2456pub struct LastError {
2457 pub kind: CardEventKind,
2458 pub msg: String,
2459 pub ts_ms: u64,
2460}
2461
2462#[derive(Default, Debug)]
2466pub struct PerSubscriber {
2467 pub ok: HashMap<CardEventKind, u64>,
2468 pub err: HashMap<CardEventKind, u64>,
2469 pub last_error: Option<LastError>,
2470}
2471
2472#[derive(Default, Debug)]
2475pub struct SubscriberStats {
2476 inner: Mutex<HashMap<String, PerSubscriber>>,
2477}
2478
2479impl SubscriberStats {
2480 pub fn record_ok(&self, key: &str, kind: CardEventKind) {
2482 let mut g = self.inner.lock().unwrap_or_else(|p| p.into_inner());
2483 let entry = g.entry(key.to_string()).or_default();
2484 let c = entry.ok.entry(kind).or_insert(0);
2485 *c = c.saturating_add(1);
2486 }
2487
2488 pub fn record_err(&self, key: &str, kind: CardEventKind, err: &str) {
2492 let mut g = self.inner.lock().unwrap_or_else(|p| p.into_inner());
2493 let entry = g.entry(key.to_string()).or_default();
2494 let c = entry.err.entry(kind).or_insert(0);
2495 *c = c.saturating_add(1);
2496 entry.last_error = Some(LastError {
2497 kind,
2498 msg: err.to_string(),
2499 ts_ms: now_ms(),
2500 });
2501 }
2502
2503 pub fn snapshot(&self) -> Vec<SubscriberHealthRow> {
2512 let g = self.inner.lock().unwrap_or_else(|p| p.into_inner());
2513 let mut rows = Vec::with_capacity(g.len());
2514 for (sink, ps) in g.iter() {
2515 let mut ok: HashMap<String, u64> = HashMap::with_capacity(4);
2516 let mut err: HashMap<String, u64> = HashMap::with_capacity(4);
2517 for k in CardEventKind::all() {
2518 ok.insert(
2519 k.json_key().to_string(),
2520 ps.ok.get(&k).copied().unwrap_or(0),
2521 );
2522 err.insert(
2523 k.json_key().to_string(),
2524 ps.err.get(&k).copied().unwrap_or(0),
2525 );
2526 }
2527 rows.push(SubscriberHealthRow {
2528 sink: sink.clone(),
2529 ok,
2530 err,
2531 last_error: ps.last_error.clone(),
2532 });
2533 }
2534 rows.sort_by(|a, b| a.sink.cmp(&b.sink));
2537 rows
2538 }
2539}
2540
2541fn now_ms() -> u64 {
2545 std::time::SystemTime::now()
2546 .duration_since(std::time::UNIX_EPOCH)
2547 .unwrap_or_default()
2548 .as_millis() as u64
2549}
2550
2551#[derive(Debug, Clone, Serialize)]
2554pub struct SubscriberHealthRow {
2555 pub sink: String,
2556 pub ok: HashMap<String, u64>,
2557 pub err: HashMap<String, u64>,
2558 pub last_error: Option<LastError>,
2559}
2560
2561pub fn subscriber_stats_snapshot() -> Vec<SubscriberHealthRow> {
2566 event_bus().stats().snapshot()
2567}
2568
2569fn atomic_write(dest: &Path, bytes: &[u8]) -> Result<(), String> {
2576 static TMP_SEQ: AtomicU64 = AtomicU64::new(0);
2577 let seq = TMP_SEQ.fetch_add(1, Ordering::Relaxed);
2578 let pid = process::id();
2579 if let Some(parent) = dest.parent() {
2580 if !parent.as_os_str().is_empty() && !parent.exists() {
2581 fs::create_dir_all(parent).map_err(|e| format!("subscriber mkdir: {e}"))?;
2582 }
2583 }
2584 let mut tmp = dest.as_os_str().to_owned();
2585 tmp.push(format!(".tmp.{pid}.{seq}"));
2586 let tmp_path = PathBuf::from(tmp);
2587 fs::write(&tmp_path, bytes).map_err(|e| format!("subscriber write tmp: {e}"))?;
2588 fs::rename(&tmp_path, dest).map_err(|e| format!("subscriber rename: {e}"))
2589}
2590
2591fn canonical_file_uri(root: &Path) -> String {
2595 let p = root.to_string_lossy();
2596 #[cfg(unix)]
2597 {
2598 format!("file://{p}")
2599 }
2600 #[cfg(windows)]
2601 {
2602 format!("file:///{}", p.replace('\\', "/"))
2603 }
2604 #[cfg(not(any(unix, windows)))]
2605 {
2606 format!("file://{p}")
2607 }
2608}
2609
2610pub struct FileCardSubscriber {
2617 root: PathBuf,
2618 uri: String,
2619}
2620
2621impl FileCardSubscriber {
2622 pub fn new(root: PathBuf) -> Self {
2625 let uri = canonical_file_uri(&root);
2626 Self { root, uri }
2627 }
2628
2629 pub fn locate_card(&self, card_id: &str) -> Result<Option<PathBuf>, String> {
2633 validate_name(card_id, "card_id")?;
2634 if !self.root.exists() {
2635 return Ok(None);
2636 }
2637 let entries = fs::read_dir(&self.root).map_err(|e| format!("subscriber read_dir: {e}"))?;
2638 for entry in entries.flatten() {
2639 let p = entry.path();
2640 if p.is_dir() {
2641 let candidate = p.join(format!("{card_id}.toml"));
2642 if candidate.exists() {
2643 return Ok(Some(candidate));
2644 }
2645 }
2646 }
2647 Ok(None)
2648 }
2649
2650 fn ensure_pkg_dir(&self, pkg: &str) -> Result<PathBuf, String> {
2651 validate_name(pkg, "pkg")?;
2652 let dir = self.root.join(pkg);
2653 if !dir.exists() {
2654 fs::create_dir_all(&dir).map_err(|e| format!("subscriber mkdir: {e}"))?;
2655 }
2656 Ok(dir)
2657 }
2658
2659 fn write_created(&self, pkg: &str, card_id: &str, toml_text: &str) -> Result<(), String> {
2660 validate_name(card_id, "card_id")?;
2661 let dir = self.ensure_pkg_dir(pkg)?;
2662 let dest = dir.join(format!("{card_id}.toml"));
2663 atomic_write(&dest, toml_text.as_bytes())
2664 }
2665
2666 fn write_appended(&self, card_id: &str, toml_text: &str) -> Result<(), String> {
2667 match self.locate_card(card_id)? {
2668 Some(dest) => atomic_write(&dest, toml_text.as_bytes()),
2669 None => Err(format!(
2670 "subscriber append: card '{card_id}' missing at {}",
2671 self.uri
2672 )),
2673 }
2674 }
2675
2676 fn write_samples(&self, card_id: &str, jsonl_text: &str) -> Result<(), String> {
2677 let card_path = self.locate_card(card_id)?.ok_or_else(|| {
2678 format!(
2679 "subscriber samples: card '{card_id}' missing at {}",
2680 self.uri
2681 )
2682 })?;
2683 let dir = card_path
2684 .parent()
2685 .ok_or_else(|| format!("subscriber samples: card '{card_id}' has no parent dir"))?;
2686 let dest = dir.join(format!("{card_id}.samples.jsonl"));
2687 atomic_write(&dest, jsonl_text.as_bytes())
2688 }
2689
2690 fn write_aliases(&self, toml_text: &str) -> Result<(), String> {
2691 if !self.root.exists() {
2692 fs::create_dir_all(&self.root).map_err(|e| format!("subscriber mkdir: {e}"))?;
2693 }
2694 let dest = self.root.join("_aliases.toml");
2695 atomic_write(&dest, toml_text.as_bytes())
2696 }
2697}
2698
2699impl CardSubscriber for FileCardSubscriber {
2700 fn on_event(&self, ev: &CardEvent) -> Result<(), String> {
2701 match ev {
2702 CardEvent::Created {
2703 pkg,
2704 card_id,
2705 toml_text,
2706 } => self.write_created(pkg, card_id, toml_text),
2707 CardEvent::Appended { card_id, toml_text } => self.write_appended(card_id, toml_text),
2708 CardEvent::SamplesWritten {
2709 card_id,
2710 jsonl_text,
2711 } => self.write_samples(card_id, jsonl_text),
2712 CardEvent::AliasesWritten { toml_text } => self.write_aliases(toml_text),
2713 }
2714 }
2715
2716 fn describe(&self) -> String {
2717 self.uri.clone()
2718 }
2719
2720 fn has_card(&self, card_id: &str) -> Result<bool, String> {
2724 Ok(self.locate_card(card_id)?.is_some())
2725 }
2726}
2727
2728#[derive(Debug)]
2746pub struct LogSinkCardSubscriber {
2747 sinks: Mutex<Vec<(u64, LogSink)>>,
2748 next_id: AtomicU64,
2749}
2750
2751impl LogSinkCardSubscriber {
2752 pub const URI: &'static str = "log-sink://alc.card";
2755
2756 fn new() -> Self {
2757 Self {
2758 sinks: Mutex::new(Vec::new()),
2759 next_id: AtomicU64::new(0),
2760 }
2761 }
2762
2763 fn format_message(ev: &CardEvent) -> String {
2769 match ev {
2770 CardEvent::Created { pkg, card_id, .. } => {
2771 format!("created pkg={pkg} card_id={card_id}")
2772 }
2773 CardEvent::Appended { card_id, .. } => {
2774 format!("appended card_id={card_id}")
2775 }
2776 CardEvent::SamplesWritten {
2777 card_id,
2778 jsonl_text,
2779 } => {
2780 format!(
2781 "samples_written card_id={card_id} bytes={}",
2782 jsonl_text.len()
2783 )
2784 }
2785 CardEvent::AliasesWritten { toml_text } => {
2786 format!("aliases_written bytes={}", toml_text.len())
2787 }
2788 }
2789 }
2790}
2791
2792impl CardSubscriber for LogSinkCardSubscriber {
2793 fn on_event(&self, ev: &CardEvent) -> Result<(), String> {
2808 let snapshot: Vec<LogSink> = {
2809 let guard = self.sinks.lock().unwrap_or_else(|p| p.into_inner());
2810 guard.iter().map(|(_, sink)| sink.clone()).collect()
2811 };
2812 let message = Self::format_message(ev);
2813 for sink in &snapshot {
2814 sink.push(LogEntry::new("info", "alc.card", message.clone()));
2815 }
2816 Ok(())
2817 }
2818
2819 fn describe(&self) -> String {
2825 Self::URI.to_string()
2826 }
2827}
2828
2829#[derive(Debug)]
2853pub struct LogSinkRegistration {
2854 subscriber: Arc<LogSinkCardSubscriber>,
2855 id: u64,
2856}
2857
2858impl LogSinkRegistration {
2859 #[cfg(any(test, feature = "test-support"))]
2863 pub fn id(&self) -> u64 {
2864 self.id
2865 }
2866}
2867
2868impl Drop for LogSinkRegistration {
2869 fn drop(&mut self) {
2870 let mut guard = self
2871 .subscriber
2872 .sinks
2873 .lock()
2874 .unwrap_or_else(|p| p.into_inner());
2875 guard.retain(|(id, _)| *id != self.id);
2876 }
2877}
2878
2879pub fn register_log_sink(sink: LogSink) -> LogSinkRegistration {
2901 let subscriber = log_sink_subscriber();
2902 let id = subscriber.next_id.fetch_add(1, Ordering::Relaxed);
2903 {
2904 let mut guard = subscriber.sinks.lock().unwrap_or_else(|p| p.into_inner());
2905 guard.push((id, sink));
2906 }
2907 LogSinkRegistration { subscriber, id }
2908}
2909
2910static LOG_SINK_SUBSCRIBER: OnceLock<Arc<LogSinkCardSubscriber>> = OnceLock::new();
2911
2912pub fn log_sink_subscriber() -> Arc<LogSinkCardSubscriber> {
2928 LOG_SINK_SUBSCRIBER
2929 .get_or_init(|| {
2930 let arc = Arc::new(LogSinkCardSubscriber::new());
2931 let bus = event_bus();
2932 if !bus
2936 .subscriber_uris()
2937 .iter()
2938 .any(|u| u == LogSinkCardSubscriber::URI)
2939 {
2940 bus.add_subscriber(arc.clone() as Arc<dyn CardSubscriber>);
2941 }
2942 arc
2943 })
2944 .clone()
2945}
2946
2947pub struct CardEventBus {
2954 subscribers: Mutex<Vec<Arc<dyn CardSubscriber>>>,
2955 stats: Arc<SubscriberStats>,
2956}
2957
2958impl CardEventBus {
2959 pub fn new(subscribers: Vec<Arc<dyn CardSubscriber>>) -> Self {
2962 Self {
2963 subscribers: Mutex::new(subscribers),
2964 stats: Arc::new(SubscriberStats::default()),
2965 }
2966 }
2967
2968 pub fn stats(&self) -> &Arc<SubscriberStats> {
2970 &self.stats
2971 }
2972
2973 pub fn publish(&self, ev: &CardEvent) {
2977 let subs_snapshot: Vec<Arc<dyn CardSubscriber>> = {
2978 let guard = self.subscribers.lock().unwrap_or_else(|p| p.into_inner());
2979 guard.clone()
2980 };
2981 for sub in &subs_snapshot {
2982 let key = sub.describe();
2983 match sub.on_event(ev) {
2984 Ok(()) => self.stats.record_ok(&key, ev.kind()),
2985 Err(e) => {
2986 tracing::warn!(
2987 subscriber = %key,
2988 kind = ev.kind().as_str(),
2989 error = %e,
2990 "card subscriber failed"
2991 );
2992 self.stats.record_err(&key, ev.kind(), &e);
2993 }
2994 }
2995 }
2996 }
2997
2998 pub fn publish_to(&self, target: &str, ev: &CardEvent) -> Result<(), String> {
3002 let hit: Option<Arc<dyn CardSubscriber>> = {
3003 let guard = self.subscribers.lock().unwrap_or_else(|p| p.into_inner());
3004 guard.iter().find(|s| s.describe() == target).cloned()
3005 };
3006 let Some(sub) = hit else {
3007 return Err(format!("subscriber not registered: {target}"));
3008 };
3009 let key = sub.describe();
3010 match sub.on_event(ev) {
3011 Ok(()) => {
3012 self.stats.record_ok(&key, ev.kind());
3013 Ok(())
3014 }
3015 Err(e) => {
3016 tracing::warn!(
3017 subscriber = %key,
3018 kind = ev.kind().as_str(),
3019 error = %e,
3020 "card subscriber failed (publish_to)"
3021 );
3022 self.stats.record_err(&key, ev.kind(), &e);
3023 Err(e)
3024 }
3025 }
3026 }
3027
3028 pub fn subscriber_uris(&self) -> Vec<String> {
3030 let guard = self.subscribers.lock().unwrap_or_else(|p| p.into_inner());
3031 guard.iter().map(|s| s.describe()).collect()
3032 }
3033
3034 pub fn find_subscriber(&self, uri: &str) -> Option<Arc<dyn CardSubscriber>> {
3039 let guard = self.subscribers.lock().unwrap_or_else(|p| p.into_inner());
3040 guard.iter().find(|s| s.describe() == uri).cloned()
3041 }
3042
3043 pub fn add_subscriber(&self, sub: Arc<dyn CardSubscriber>) {
3064 let mut guard = self.subscribers.lock().unwrap_or_else(|p| p.into_inner());
3065 guard.push(sub);
3066 }
3067
3068 #[cfg(any(test, feature = "test-support"))]
3071 pub fn replace_subscribers_for_test(&self, subs: Vec<Arc<dyn CardSubscriber>>) {
3072 let mut guard = self.subscribers.lock().unwrap_or_else(|p| p.into_inner());
3073 *guard = subs;
3074 }
3075
3076 #[cfg(any(test, feature = "test-support"))]
3078 pub fn reset_stats_for_test(&self) {
3079 let mut g = self.stats.inner.lock().unwrap_or_else(|p| p.into_inner());
3080 g.clear();
3081 }
3082}
3083
3084static CARD_EVENT_BUS: OnceLock<CardEventBus> = OnceLock::new();
3085
3086pub fn event_bus() -> &'static CardEventBus {
3089 CARD_EVENT_BUS.get_or_init(|| {
3090 let subs = load_subscribers_from_env();
3091 CardEventBus::new(subs)
3092 })
3093}
3094
3095pub fn init_event_bus() {
3100 let bus = event_bus();
3101 let uris = bus.subscriber_uris();
3102 if uris.is_empty() {
3103 tracing::info!("card sinks: no subscribers configured (ALC_CARD_SINKS unset)");
3104 } else {
3105 for uri in &uris {
3106 tracing::info!(subscriber = %uri, "card sink registered");
3107 }
3108 }
3109}
3110
3111#[cfg(any(test, feature = "test-support"))]
3113pub fn install_event_bus_for_test(bus: CardEventBus) -> Result<(), String> {
3114 CARD_EVENT_BUS
3115 .set(bus)
3116 .map_err(|_| "bus already initialized".to_string())
3117}
3118
3119pub fn publish(ev: CardEvent) {
3121 #[cfg(test)]
3127 {
3128 let is_test_owner = INSIDE_BUS_TEST.with(|f| f.get());
3129 if !is_test_owner {
3130 let _gate = bus_test_gate().lock().unwrap_or_else(|p| p.into_inner());
3132 event_bus().publish(&ev);
3133 return;
3134 }
3135 }
3136 event_bus().publish(&ev);
3137}
3138
3139#[cfg(test)]
3142fn bus_test_gate() -> &'static Mutex<()> {
3143 static GATE: OnceLock<Mutex<()>> = OnceLock::new();
3144 GATE.get_or_init(|| Mutex::new(()))
3145}
3146
3147#[cfg(test)]
3150thread_local! {
3151 static INSIDE_BUS_TEST: std::cell::Cell<bool> = const { std::cell::Cell::new(false) };
3152}
3153
3154#[derive(Debug, Clone, Default, Serialize)]
3161pub struct SinkBackfillReport {
3162 pub sink: String,
3163 pub pushed: Vec<String>,
3164 pub skipped: Vec<String>,
3165 pub failed: Vec<(String, String)>,
3166 pub pushed_samples: Vec<String>,
3167}
3168
3169pub fn card_sink_backfill_with_store(
3189 store: &dyn CardStore,
3190 sink: &str,
3191 dry_run: bool,
3192) -> Result<SinkBackfillReport, String> {
3193 let bus = event_bus();
3194 let sub = bus
3195 .find_subscriber(sink)
3196 .ok_or_else(|| format!("unknown sink: {sink}"))?;
3197
3198 let locators = store.list_card_locators(None)?;
3199
3200 let mut report = SinkBackfillReport {
3201 sink: sink.to_string(),
3202 ..Default::default()
3203 };
3204
3205 for (pkg, locator) in locators {
3206 let card_id = match locator.file_stem().and_then(|s| s.to_str()) {
3207 Some(s) => s.to_string(),
3208 None => continue,
3209 };
3210
3211 match sub.has_card(&card_id) {
3212 Ok(true) => {
3213 report.skipped.push(card_id);
3214 continue;
3215 }
3216 Ok(false) => {}
3217 Err(e) => {
3218 tracing::warn!(
3219 card_id = %card_id,
3220 error = %e,
3221 "backfill: has_card failed; treating as skipped"
3222 );
3223 report.skipped.push(card_id);
3224 continue;
3225 }
3226 }
3227
3228 let toml_text = match store.read_locator_text(&locator) {
3229 Ok(Some(t)) => t,
3230 Ok(None) => {
3231 report.skipped.push(card_id);
3233 continue;
3234 }
3235 Err(e) => {
3236 tracing::warn!(
3237 card_id = %card_id,
3238 error = %e,
3239 "backfill: read_locator_text failed; treating as skipped"
3240 );
3241 report.skipped.push(card_id);
3242 continue;
3243 }
3244 };
3245
3246 if dry_run {
3247 report.pushed.push(card_id.clone());
3248 if matches!(store.read_samples_text(&card_id), Ok(Some(_))) {
3249 report.pushed_samples.push(card_id);
3250 }
3251 continue;
3252 }
3253
3254 let ev = CardEvent::Created {
3255 pkg: pkg.clone(),
3256 card_id: card_id.clone(),
3257 toml_text,
3258 };
3259 match bus.publish_to(sink, &ev) {
3260 Ok(()) => report.pushed.push(card_id.clone()),
3261 Err(e) => {
3262 report.failed.push((card_id, e));
3263 continue;
3264 }
3265 }
3266
3267 if let Ok(Some(jsonl_text)) = store.read_samples_text(&card_id) {
3268 let ev = CardEvent::SamplesWritten {
3269 card_id: card_id.clone(),
3270 jsonl_text,
3271 };
3272 match bus.publish_to(sink, &ev) {
3273 Ok(()) => report.pushed_samples.push(card_id),
3274 Err(e) => {
3275 report.failed.push((card_id, format!("samples: {e}")));
3276 }
3277 }
3278 }
3279 }
3280
3281 Ok(report)
3282}
3283
3284fn load_subscribers_from_env() -> Vec<Arc<dyn CardSubscriber>> {
3290 let raw = match std::env::var("ALC_CARD_SINKS") {
3291 Ok(v) => v,
3292 Err(std::env::VarError::NotPresent) => return Vec::new(),
3293 Err(std::env::VarError::NotUnicode(_)) => {
3294 tracing::error!("ALC_CARD_SINKS contains non-UTF8 bytes; ignoring entire variable");
3295 return Vec::new();
3296 }
3297 };
3298 parse_subscribers_from_str(&raw)
3299}
3300
3301fn parse_subscribers_from_str(raw: &str) -> Vec<Arc<dyn CardSubscriber>> {
3305 if raw.is_empty() {
3306 return Vec::new();
3307 }
3308 let mut seen: HashSet<String> = HashSet::new();
3309 let mut out: Vec<Arc<dyn CardSubscriber>> = Vec::new();
3310 for spec in raw.split('|') {
3311 let spec = spec.trim();
3312 if spec.is_empty() {
3313 continue;
3314 }
3315 let Some(sub) = parse_subscriber_spec(spec) else {
3316 continue;
3317 };
3318 let uri = sub.describe();
3319 if !seen.insert(uri.clone()) {
3320 tracing::warn!(subscriber = %uri, "duplicate ALC_CARD_SINKS entry; keeping first");
3321 continue;
3322 }
3323 out.push(sub);
3324 }
3325 out
3326}
3327
3328fn parse_subscriber_spec(spec: &str) -> Option<Arc<dyn CardSubscriber>> {
3330 let Some(colon_idx) = spec.find(':') else {
3332 tracing::error!(spec, "ALC_CARD_SINKS entry missing URI scheme");
3333 return None;
3334 };
3335 let scheme = &spec[..colon_idx];
3336 let rest = &spec[colon_idx + 1..];
3337 if scheme != "file" {
3338 tracing::error!(spec, scheme, "ALC_CARD_SINKS entry has unknown scheme");
3339 return None;
3340 }
3341 let Some(after_slash) = rest.strip_prefix("//") else {
3343 tracing::error!(spec, "file URI missing '//'");
3344 return None;
3345 };
3346 let Some(path_start) = after_slash.find('/') else {
3348 tracing::error!(spec, "file URI has no path component");
3349 return None;
3350 };
3351 let authority = &after_slash[..path_start];
3352 let encoded_path = &after_slash[path_start..];
3353 if !authority.is_empty() {
3354 tracing::error!(
3355 spec,
3356 authority,
3357 "file URI with non-empty authority is rejected"
3358 );
3359 return None;
3360 }
3361 let path = decode_file_uri_path(encoded_path)?;
3362 Some(Arc::new(FileCardSubscriber::new(path)))
3363}
3364
3365fn decode_file_uri_path(encoded: &str) -> Option<PathBuf> {
3371 let decoded = percent_decode(encoded)?;
3372 #[cfg(windows)]
3373 {
3374 let trimmed = decoded.strip_prefix('/').unwrap_or(&decoded);
3376 Some(PathBuf::from(trimmed))
3377 }
3378 #[cfg(not(windows))]
3379 {
3380 Some(PathBuf::from(decoded))
3381 }
3382}
3383
3384fn percent_decode(src: &str) -> Option<String> {
3387 let bytes = src.as_bytes();
3388 let mut out: Vec<u8> = Vec::with_capacity(bytes.len());
3389 let mut i = 0;
3390 while i < bytes.len() {
3391 let b = bytes[i];
3392 if b == b'%' {
3393 if i + 2 >= bytes.len() {
3394 tracing::error!(src, "percent-encoded sequence truncated");
3395 return None;
3396 }
3397 let hi = (bytes[i + 1] as char).to_digit(16);
3398 let lo = (bytes[i + 2] as char).to_digit(16);
3399 match (hi, lo) {
3400 (Some(h), Some(l)) => {
3401 out.push(((h << 4) | l) as u8);
3402 i += 3;
3403 }
3404 _ => {
3405 tracing::error!(src, "percent-encoded sequence has non-hex digits");
3406 return None;
3407 }
3408 }
3409 } else {
3410 out.push(b);
3411 i += 1;
3412 }
3413 }
3414 match String::from_utf8(out) {
3415 Ok(s) => Some(s),
3416 Err(_) => {
3417 tracing::error!(src, "percent-decoded bytes are not valid UTF-8");
3418 None
3419 }
3420 }
3421}
3422
3423#[cfg(test)]
3424mod tests {
3425 use super::*;
3426
3427 #[test]
3432 fn run_section_accepts_all_three_statuses() {
3433 for status in ["succeeded", "failed", "skipped"] {
3434 let input = json!({ "run": { "status": status } });
3435 let parsed = RunSection::from_json(&input)
3436 .unwrap_or_else(|e| panic!("status '{status}' should parse: {e}"));
3437 let section = parsed.expect("run field present must yield Some");
3438 let expected = match status {
3439 "succeeded" => RunStatus::Succeeded,
3440 "failed" => RunStatus::Failed,
3441 "skipped" => RunStatus::Skipped,
3442 _ => unreachable!(),
3443 };
3444 assert_eq!(section.status, expected);
3445 }
3446 }
3447
3448 #[test]
3453 fn run_section_rejects_invalid_status() {
3454 let input = json!({ "run": { "status": "unknown_status_xyz" } });
3455 let err = RunSection::from_json(&input).unwrap_err();
3456 assert!(
3457 err.contains("succeeded") && err.contains("failed") && err.contains("skipped"),
3458 "error message must enumerate accepted status tokens, got: {err}"
3459 );
3460 }
3461
3462 #[test]
3466 fn run_section_absent_returns_none() {
3467 let input = json!({ "pkg": { "name": "no_run_pkg" } });
3468 let parsed = RunSection::from_json(&input).unwrap();
3469 assert!(parsed.is_none(), "absent run field must yield None");
3470 }
3471
3472 #[test]
3476 fn run_section_optional_fields_default_none() {
3477 let input = json!({ "run": { "status": "succeeded" } });
3478 let section = RunSection::from_json(&input).unwrap().unwrap();
3479 assert_eq!(section.status, RunStatus::Succeeded);
3480 assert!(section.reason.is_none());
3481 assert!(section.action.is_none());
3482 }
3483
3484 #[test]
3486 fn run_section_all_fields_present() {
3487 let input = json!({
3488 "run": {
3489 "status": "failed",
3490 "reason": "grader rating < 3",
3491 "action": "write",
3492 }
3493 });
3494 let section = RunSection::from_json(&input).unwrap().unwrap();
3495 assert_eq!(section.status, RunStatus::Failed);
3496 assert_eq!(section.reason.as_deref(), Some("grader rating < 3"));
3497 assert_eq!(section.action.as_deref(), Some("write"));
3498 }
3499
3500 #[test]
3510 fn load_full_preserves_run_section_when_present() {
3511 let tmp = tempfile::tempdir().unwrap();
3512 let store = FileCardStore::new(tmp.path().to_path_buf());
3513 let pkg = "run_present_pkg";
3514 let card_id = "run_present_pkg_20260101T000000_abcdef";
3515
3516 let toml_text = format!(
3520 r#"schema_version = "card/v0"
3521card_id = "{card_id}"
3522created_at = "2026-01-01T00:00:00Z"
3523
3524[pkg]
3525name = "{pkg}"
3526
3527[run]
3528status = "failed"
3529reason = "grader rating < 3"
3530action = "write"
3531"#
3532 );
3533 let pkg_dir = tmp.path().join(pkg);
3534 fs::create_dir_all(&pkg_dir).unwrap();
3535 let card_path = pkg_dir.join(format!("{card_id}.toml"));
3536 fs::write(&card_path, &toml_text).unwrap();
3537
3538 let row = load_full(&store, &card_path, pkg)
3539 .expect("load_full must succeed for a well-formed Card with [run] section");
3540
3541 let run = row
3542 .full
3543 .get("run")
3544 .expect("run field must survive TOML → JSON round-trip");
3545 assert_eq!(
3546 run.get("status").and_then(|v| v.as_str()),
3547 Some("failed"),
3548 "status must be preserved verbatim"
3549 );
3550 assert_eq!(
3551 run.get("reason").and_then(|v| v.as_str()),
3552 Some("grader rating < 3"),
3553 "reason must be preserved verbatim"
3554 );
3555 assert_eq!(
3556 run.get("action").and_then(|v| v.as_str()),
3557 Some("write"),
3558 "action must be preserved verbatim"
3559 );
3560 }
3561
3562 #[test]
3567 fn load_full_returns_no_run_field_when_absent() {
3568 let tmp = tempfile::tempdir().unwrap();
3569 let store = FileCardStore::new(tmp.path().to_path_buf());
3570 let pkg = "run_absent_pkg";
3571 let card_id = "run_absent_pkg_20260101T000000_abcdef";
3572
3573 let toml_text = format!(
3574 r#"schema_version = "card/v0"
3575card_id = "{card_id}"
3576created_at = "2026-01-01T00:00:00Z"
3577
3578[pkg]
3579name = "{pkg}"
3580"#
3581 );
3582 let pkg_dir = tmp.path().join(pkg);
3583 fs::create_dir_all(&pkg_dir).unwrap();
3584 let card_path = pkg_dir.join(format!("{card_id}.toml"));
3585 fs::write(&card_path, &toml_text).unwrap();
3586
3587 let row = load_full(&store, &card_path, pkg)
3588 .expect("load_full must succeed for a Card without [run] section");
3589
3590 assert!(
3591 row.full.get("run").is_none(),
3592 "absent [run] section must not surface as a JSON field (backward compat)"
3593 );
3594 }
3595
3596 #[test]
3597 fn minimum_valid_card() {
3598 let tmp = tempfile::tempdir().unwrap();
3599 let store = FileCardStore::new(tmp.path().to_path_buf());
3600 let pkg = "minimum_valid_pkg";
3601 let input = json!({ "pkg": { "name": pkg } });
3602 let (id, path) = create_with_store(&store, input).unwrap();
3603 assert!(path.exists());
3604 assert!(id.starts_with(pkg));
3605
3606 let got = get_with_store(&store, &id).unwrap().unwrap();
3607 assert_eq!(got["schema_version"], json!(SCHEMA_VERSION));
3608 assert_eq!(got["card_id"], json!(id));
3609 assert_eq!(got["pkg"]["name"], json!(pkg));
3610 assert!(got.get("created_at").is_some());
3611 assert!(got.get("created_by").is_some());
3612 }
3613
3614 #[test]
3615 fn create_rejects_missing_pkg_name() {
3616 let tmp = tempfile::tempdir().unwrap();
3617 let store = FileCardStore::new(tmp.path().to_path_buf());
3618 let err = create_with_store(&store, json!({})).unwrap_err();
3619 assert!(err.contains("pkg.name"));
3620 }
3621
3622 #[test]
3623 fn create_is_immutable() {
3624 let tmp = tempfile::tempdir().unwrap();
3625 let store = FileCardStore::new(tmp.path().to_path_buf());
3626 let pkg = "immutable_pkg";
3627 let input = json!({
3628 "card_id": "fixed_id_001",
3629 "pkg": { "name": pkg }
3630 });
3631 create_with_store(&store, input.clone()).unwrap();
3632 let err = create_with_store(&store, input).unwrap_err();
3633 assert!(err.contains("already exists"));
3634 }
3635
3636 #[test]
3637 fn create_injects_param_fingerprint() {
3638 let tmp = tempfile::tempdir().unwrap();
3639 let store = FileCardStore::new(tmp.path().to_path_buf());
3640 let pkg = "fingerprint_pkg";
3641 let input = json!({
3642 "pkg": { "name": pkg },
3643 "params": { "depth": 3, "temperature": 0.0 }
3644 });
3645 let (id, _) = create_with_store(&store, input).unwrap();
3646 let got = get_with_store(&store, &id).unwrap().unwrap();
3647 assert!(got["param_fingerprint"].is_string());
3648 }
3649
3650 #[test]
3651 fn list_returns_newest_first() {
3652 let tmp = tempfile::tempdir().unwrap();
3653 let store = FileCardStore::new(tmp.path().to_path_buf());
3654 let pkg = "list_newest_pkg";
3655 let (id1, _) = create_with_store(
3656 &store,
3657 json!({
3658 "card_id": format!("{pkg}_a"),
3659 "pkg": { "name": pkg },
3660 "created_at": "2025-01-01T00:00:00Z"
3661 }),
3662 )
3663 .unwrap();
3664 let (id2, _) = create_with_store(
3665 &store,
3666 json!({
3667 "card_id": format!("{pkg}_b"),
3668 "pkg": { "name": pkg },
3669 "created_at": "2026-01-01T00:00:00Z"
3670 }),
3671 )
3672 .unwrap();
3673
3674 let rows = list_with_store(&store, Some(pkg)).unwrap();
3675 assert_eq!(rows.len(), 2);
3676 assert_eq!(rows[0].card_id, id2); assert_eq!(rows[1].card_id, id1);
3678 }
3679
3680 #[test]
3681 fn list_extracts_summary_fields() {
3682 let tmp = tempfile::tempdir().unwrap();
3683 let store = FileCardStore::new(tmp.path().to_path_buf());
3684 let pkg = "list_summary_pkg";
3685 let (id, _) = create_with_store(
3686 &store,
3687 json!({
3688 "pkg": { "name": pkg },
3689 "model": { "id": "claude-opus-4-6" },
3690 "scenario": { "name": "gsm8k_sample100" },
3691 "stats": { "pass_rate": 0.82 }
3692 }),
3693 )
3694 .unwrap();
3695
3696 let rows = list_with_store(&store, Some(pkg)).unwrap();
3697 let row = rows.iter().find(|r| r.card_id == id).unwrap();
3698 assert_eq!(row.model.as_deref(), Some("claude-opus-4-6"));
3699 assert_eq!(row.scenario.as_deref(), Some("gsm8k_sample100"));
3700 assert_eq!(row.pass_rate, Some(0.82));
3701 }
3702
3703 #[test]
3704 fn get_missing_returns_none() {
3705 let tmp = tempfile::tempdir().unwrap();
3706 let store = FileCardStore::new(tmp.path().to_path_buf());
3707 assert!(get_with_store(&store, "does_not_exist_xyz")
3708 .unwrap()
3709 .is_none());
3710 }
3711
3712 #[test]
3713 fn card_id_embeds_compact_timestamp() {
3714 let tmp = tempfile::tempdir().unwrap();
3715 let store = FileCardStore::new(tmp.path().to_path_buf());
3716 let pkg = "ts_embed_pkg";
3717 let (id, _) = create_with_store(&store, json!({ "pkg": { "name": pkg } })).unwrap();
3718 let tail = id.strip_prefix(&format!("{pkg}_")).unwrap();
3722 let parts: Vec<&str> = tail.split('_').collect();
3723 assert_eq!(parts.len(), 3, "unexpected card_id shape: {id}");
3725 let ts = parts[1];
3726 assert_eq!(ts.len(), 15, "timestamp segment wrong length: {ts}");
3727 assert!(ts.chars().nth(8) == Some('T'), "missing T separator: {ts}");
3728 }
3729
3730 #[test]
3731 fn now_compact_format() {
3732 let s = now_compact();
3733 assert_eq!(s.len(), 15);
3734 assert_eq!(s.chars().nth(8), Some('T'));
3735 for (i, c) in s.chars().enumerate() {
3737 if i != 8 {
3738 assert!(c.is_ascii_digit(), "non-digit at pos {i}: {s}");
3739 }
3740 }
3741 }
3742
3743 #[test]
3744 fn short_model_variants() {
3745 assert_eq!(short_model("claude-opus-4-6"), "opus46");
3746 assert_eq!(short_model("gpt-4o"), "4o");
3747 assert_eq!(short_model(""), "model");
3748 }
3749
3750 #[test]
3751 fn two_cards_same_second_different_stats_get_distinct_ids() {
3752 let tmp = tempfile::tempdir().unwrap();
3753 let store = FileCardStore::new(tmp.path().to_path_buf());
3754 let pkg = "distinct_ids_pkg";
3755 let input1 = json!({
3756 "pkg": { "name": pkg },
3757 "scenario": { "name": "gsm8k" },
3758 "stats": { "pass_rate": 0.4 }
3759 });
3760 let input2 = json!({
3761 "pkg": { "name": pkg },
3762 "scenario": { "name": "gsm8k" },
3763 "stats": { "pass_rate": 0.9 }
3764 });
3765 let (id1, _) = create_with_store(&store, input1).unwrap();
3766 let (id2, _) = create_with_store(&store, input2).unwrap();
3767 assert_ne!(id1, id2, "distinct stats must yield distinct card_ids");
3768 }
3769
3770 #[test]
3773 fn append_adds_new_fields() {
3774 let tmp = tempfile::tempdir().unwrap();
3775 let store = FileCardStore::new(tmp.path().to_path_buf());
3776 let pkg = "append_new_fields_pkg";
3777 let (id, _) = create_with_store(
3778 &store,
3779 json!({
3780 "pkg": { "name": pkg },
3781 "stats": { "pass_rate": 0.5 }
3782 }),
3783 )
3784 .unwrap();
3785
3786 let merged = append_with_store(
3787 &store,
3788 &id,
3789 json!({
3790 "caveats": { "notes": "rescored after fix" },
3791 "metadata": { "reviewer": "yn" }
3792 }),
3793 )
3794 .unwrap();
3795 assert_eq!(merged["caveats"]["notes"], json!("rescored after fix"));
3796 assert_eq!(merged["metadata"]["reviewer"], json!("yn"));
3797
3798 let got = get_with_store(&store, &id).unwrap().unwrap();
3800 assert_eq!(got["caveats"]["notes"], json!("rescored after fix"));
3801 assert_eq!(got["stats"]["pass_rate"], json!(0.5));
3803 }
3804
3805 #[test]
3806 fn append_rejects_existing_key() {
3807 let tmp = tempfile::tempdir().unwrap();
3808 let store = FileCardStore::new(tmp.path().to_path_buf());
3809 let pkg = "append_reject_key_pkg";
3810 let (id, _) = create_with_store(
3811 &store,
3812 json!({
3813 "pkg": { "name": pkg },
3814 "stats": { "pass_rate": 0.5 }
3815 }),
3816 )
3817 .unwrap();
3818
3819 let err =
3820 append_with_store(&store, &id, json!({ "stats": { "pass_rate": 0.9 } })).unwrap_err();
3821 assert!(err.contains("already set"), "got: {err}");
3822 let got = get_with_store(&store, &id).unwrap().unwrap();
3824 assert_eq!(got["stats"]["pass_rate"], json!(0.5));
3825 }
3826
3827 #[test]
3828 fn append_errors_on_missing_card() {
3829 let tmp = tempfile::tempdir().unwrap();
3830 let store = FileCardStore::new(tmp.path().to_path_buf());
3831 let err = append_with_store(&store, "does_not_exist_xyz", json!({ "x": 1 })).unwrap_err();
3832 assert!(err.contains("not found"));
3833 }
3834
3835 #[test]
3838 fn alias_set_and_list_roundtrip() {
3839 let tmp = tempfile::tempdir().unwrap();
3840 let store = FileCardStore::new(tmp.path().to_path_buf());
3841 let pkg = "alias_roundtrip_pkg";
3842 let (id, _) = create_with_store(&store, json!({ "pkg": { "name": pkg } })).unwrap();
3843
3844 let alias_name = "test_alias_roundtrip";
3845 alias_set_with_store(&store, alias_name, &id, Some(pkg), Some("smoke")).unwrap();
3846
3847 let rows = alias_list_with_store(&store, Some(pkg)).unwrap();
3848 let a = rows.iter().find(|a| a.name == alias_name).unwrap();
3849 assert_eq!(a.card_id, id);
3850 assert_eq!(a.pkg.as_deref(), Some(pkg));
3851 assert_eq!(a.note.as_deref(), Some("smoke"));
3852 assert!(!a.set_at.is_empty());
3853
3854 let (id2, _) = create_with_store(
3856 &store,
3857 json!({
3858 "card_id": format!("{pkg}_b"),
3859 "pkg": { "name": pkg }
3860 }),
3861 )
3862 .unwrap();
3863 alias_set_with_store(&store, alias_name, &id2, Some(pkg), None).unwrap();
3864 let rows = alias_list_with_store(&store, Some(pkg)).unwrap();
3865 let matching: Vec<&Alias> = rows.iter().filter(|a| a.name == alias_name).collect();
3866 assert_eq!(matching.len(), 1, "alias should be unique by name");
3867 assert_eq!(matching[0].card_id, id2);
3868 }
3869
3870 #[test]
3871 fn alias_set_rejects_unknown_card() {
3872 let tmp = tempfile::tempdir().unwrap();
3873 let store = FileCardStore::new(tmp.path().to_path_buf());
3874 let err = alias_set_with_store(&store, "x", "does_not_exist_xyz", None, None).unwrap_err();
3875 assert!(err.contains("not found"));
3876 }
3877
3878 fn where_from(v: Json) -> Predicate {
3881 parse_where(&v).expect("parse where")
3882 }
3883
3884 fn order_from(v: Json) -> Vec<OrderKey> {
3885 parse_order_by(&v).expect("parse order_by")
3886 }
3887
3888 #[test]
3889 fn find_where_nested_eq_and_gte() {
3890 let tmp = tempfile::tempdir().unwrap();
3891 let store = FileCardStore::new(tmp.path().to_path_buf());
3892 let pkg = "find_nested_eq_pkg";
3893 create_with_store(
3894 &store,
3895 json!({
3896 "card_id": format!("{pkg}_low"),
3897 "pkg": { "name": pkg },
3898 "scenario": { "name": "gsm8k" },
3899 "stats": { "pass_rate": 0.4 }
3900 }),
3901 )
3902 .unwrap();
3903 create_with_store(
3904 &store,
3905 json!({
3906 "card_id": format!("{pkg}_high"),
3907 "pkg": { "name": pkg },
3908 "scenario": { "name": "gsm8k" },
3909 "stats": { "pass_rate": 0.9 }
3910 }),
3911 )
3912 .unwrap();
3913 create_with_store(
3914 &store,
3915 json!({
3916 "card_id": format!("{pkg}_other"),
3917 "pkg": { "name": pkg },
3918 "scenario": { "name": "other" },
3919 "stats": { "pass_rate": 1.0 }
3920 }),
3921 )
3922 .unwrap();
3923
3924 let rows = find_with_store(
3926 &store,
3927 FindQuery {
3928 pkg: Some(pkg.to_string()),
3929 where_: Some(where_from(json!({
3930 "scenario": { "name": "gsm8k" },
3931 }))),
3932 order_by: order_from(json!("-stats.pass_rate")),
3933 ..Default::default()
3934 },
3935 )
3936 .unwrap();
3937 assert_eq!(rows.len(), 2);
3938 assert_eq!(rows[0].pass_rate, Some(0.9));
3939 assert_eq!(rows[1].pass_rate, Some(0.4));
3940
3941 let rows = find_with_store(
3943 &store,
3944 FindQuery {
3945 pkg: Some(pkg.to_string()),
3946 where_: Some(where_from(json!({
3947 "stats": { "pass_rate": { "gte": 0.8 } },
3948 }))),
3949 order_by: order_from(json!("-stats.pass_rate")),
3950 ..Default::default()
3951 },
3952 )
3953 .unwrap();
3954 assert_eq!(rows.len(), 2);
3955 assert!(rows.iter().all(|r| r.pass_rate.unwrap() >= 0.8));
3956
3957 let rows = find_with_store(
3959 &store,
3960 FindQuery {
3961 pkg: Some(pkg.to_string()),
3962 order_by: order_from(json!("-stats.pass_rate")),
3963 limit: Some(1),
3964 ..Default::default()
3965 },
3966 )
3967 .unwrap();
3968 assert_eq!(rows.len(), 1);
3969 assert_eq!(rows[0].pass_rate, Some(1.0));
3970 }
3971
3972 #[test]
3973 fn find_where_implicit_eq_and_logical() {
3974 let tmp = tempfile::tempdir().unwrap();
3975 let store = FileCardStore::new(tmp.path().to_path_buf());
3976 let pkg = "find_implicit_eq_pkg";
3977 create_with_store(
3978 &store,
3979 json!({
3980 "card_id": format!("{pkg}_a"),
3981 "pkg": { "name": pkg },
3982 "model": { "id": "claude-opus-4-6" },
3983 "stats": { "equilibrium_position": "dead", "survival_rate": 0.0 }
3984 }),
3985 )
3986 .unwrap();
3987 create_with_store(
3988 &store,
3989 json!({
3990 "card_id": format!("{pkg}_b"),
3991 "pkg": { "name": pkg },
3992 "model": { "id": "claude-opus-4-6" },
3993 "stats": { "equilibrium_position": "niche_leader", "survival_rate": 1.0 }
3994 }),
3995 )
3996 .unwrap();
3997 create_with_store(
3998 &store,
3999 json!({
4000 "card_id": format!("{pkg}_c"),
4001 "pkg": { "name": pkg },
4002 "model": { "id": "claude-haiku-4-5-20251001" },
4003 "stats": { "equilibrium_position": "fragile", "survival_rate": 0.2 }
4004 }),
4005 )
4006 .unwrap();
4007
4008 let rows = find_with_store(
4010 &store,
4011 FindQuery {
4012 pkg: Some(pkg.to_string()),
4013 where_: Some(where_from(json!({
4014 "stats": { "equilibrium_position": "dead" },
4015 }))),
4016 ..Default::default()
4017 },
4018 )
4019 .unwrap();
4020 assert_eq!(rows.len(), 1);
4021 assert!(rows[0].card_id.ends_with("_a"));
4022
4023 let rows = find_with_store(
4025 &store,
4026 FindQuery {
4027 pkg: Some(pkg.to_string()),
4028 where_: Some(where_from(json!({
4029 "_or": [
4030 { "stats": { "equilibrium_position": "dead" } },
4031 { "stats": { "survival_rate": { "gte": 0.9 } } },
4032 ],
4033 }))),
4034 ..Default::default()
4035 },
4036 )
4037 .unwrap();
4038 assert_eq!(rows.len(), 2);
4039
4040 let rows = find_with_store(
4042 &store,
4043 FindQuery {
4044 pkg: Some(pkg.to_string()),
4045 where_: Some(where_from(json!({
4046 "_not": { "model": { "id": "claude-haiku-4-5-20251001" } },
4047 }))),
4048 ..Default::default()
4049 },
4050 )
4051 .unwrap();
4052 assert_eq!(rows.len(), 2);
4053
4054 let rows = find_with_store(
4056 &store,
4057 FindQuery {
4058 pkg: Some(pkg.to_string()),
4059 where_: Some(where_from(json!({
4060 "stats": {
4061 "equilibrium_position": { "in": ["dead", "fragile"] },
4062 },
4063 }))),
4064 ..Default::default()
4065 },
4066 )
4067 .unwrap();
4068 assert_eq!(rows.len(), 2);
4069
4070 let rows = find_with_store(
4073 &store,
4074 FindQuery {
4075 pkg: Some(pkg.to_string()),
4076 where_: Some(where_from(json!({
4077 "strategy_params": { "temperature": { "exists": false } },
4078 }))),
4079 ..Default::default()
4080 },
4081 )
4082 .unwrap();
4083 assert_eq!(rows.len(), 3, "none of the cards have strategy_params");
4084 }
4085
4086 #[test]
4087 fn find_order_by_multi_key() {
4088 let tmp = tempfile::tempdir().unwrap();
4089 let store = FileCardStore::new(tmp.path().to_path_buf());
4090 let pkg = "find_order_multi_pkg";
4091 create_with_store(
4092 &store,
4093 json!({
4094 "card_id": format!("{pkg}_a"),
4095 "pkg": { "name": pkg },
4096 "stats": { "pass_rate": 0.5 }
4097 }),
4098 )
4099 .unwrap();
4100 create_with_store(
4101 &store,
4102 json!({
4103 "card_id": format!("{pkg}_b"),
4104 "pkg": { "name": pkg },
4105 "stats": { "pass_rate": 0.9 }
4106 }),
4107 )
4108 .unwrap();
4109 create_with_store(
4110 &store,
4111 json!({
4112 "card_id": format!("{pkg}_c"),
4113 "pkg": { "name": pkg },
4114 "stats": { "pass_rate": 0.9 }
4115 }),
4116 )
4117 .unwrap();
4118
4119 let rows = find_with_store(
4120 &store,
4121 FindQuery {
4122 pkg: Some(pkg.to_string()),
4123 order_by: order_from(json!(["-stats.pass_rate", "card_id"])),
4124 ..Default::default()
4125 },
4126 )
4127 .unwrap();
4128 assert_eq!(rows.len(), 3);
4129 assert_eq!(rows[0].pass_rate, Some(0.9));
4130 assert_eq!(rows[1].pass_rate, Some(0.9));
4131 assert_eq!(rows[2].pass_rate, Some(0.5));
4132 assert!(rows[0].card_id < rows[1].card_id);
4134 }
4135
4136 #[test]
4137 fn find_offset_and_limit() {
4138 let tmp = tempfile::tempdir().unwrap();
4139 let store = FileCardStore::new(tmp.path().to_path_buf());
4140 let pkg = "find_offset_limit_pkg";
4141 for i in 0..5 {
4142 create_with_store(
4143 &store,
4144 json!({
4145 "card_id": format!("{pkg}_{i}"),
4146 "pkg": { "name": pkg },
4147 "stats": { "pass_rate": 0.1 * (i + 1) as f64 }
4148 }),
4149 )
4150 .unwrap();
4151 }
4152
4153 let rows = find_with_store(
4154 &store,
4155 FindQuery {
4156 pkg: Some(pkg.to_string()),
4157 order_by: order_from(json!("-stats.pass_rate")),
4158 offset: Some(1),
4159 limit: Some(2),
4160 ..Default::default()
4161 },
4162 )
4163 .unwrap();
4164 assert_eq!(rows.len(), 2);
4165 let pr0 = rows[0].pass_rate.unwrap();
4167 let pr1 = rows[1].pass_rate.unwrap();
4168 assert!((pr0 - 0.4).abs() < 1e-9, "got {pr0}");
4169 assert!((pr1 - 0.3).abs() < 1e-9, "got {pr1}");
4170 }
4171
4172 #[test]
4173 fn parse_where_rejects_non_object() {
4174 assert!(parse_where(&json!("not an object")).is_err());
4175 assert!(parse_where(&json!(42)).is_err());
4176 }
4177
4178 #[test]
4179 fn parse_order_by_accepts_string_and_array() {
4180 let k = parse_order_by(&json!("-stats.pass_rate")).unwrap();
4181 assert_eq!(k.len(), 1);
4182 assert_eq!(k[0].path, vec!["stats", "pass_rate"]);
4183 assert!(k[0].desc);
4184
4185 let k = parse_order_by(&json!(["created_at", "-stats.n"])).unwrap();
4186 assert_eq!(k.len(), 2);
4187 assert!(!k[0].desc);
4188 assert!(k[1].desc);
4189 }
4190
4191 #[test]
4192 fn find_where_string_ops_contains_and_starts_with() {
4193 let tmp = tempfile::tempdir().unwrap();
4194 let store = FileCardStore::new(tmp.path().to_path_buf());
4195 let pkg = "find_string_ops_pkg";
4196 create_with_store(
4197 &store,
4198 json!({
4199 "card_id": format!("{pkg}_a"),
4200 "pkg": { "name": pkg },
4201 "model": { "id": "claude-opus-4-6" },
4202 "metadata": { "tag": "experiment_alpha" },
4203 }),
4204 )
4205 .unwrap();
4206 create_with_store(
4207 &store,
4208 json!({
4209 "card_id": format!("{pkg}_b"),
4210 "pkg": { "name": pkg },
4211 "model": { "id": "claude-haiku-4-5-20251001" },
4212 "metadata": { "tag": "experiment_beta" },
4213 }),
4214 )
4215 .unwrap();
4216 create_with_store(
4217 &store,
4218 json!({
4219 "card_id": format!("{pkg}_c"),
4220 "pkg": { "name": pkg },
4221 "model": { "id": "claude-sonnet-4-5" },
4222 "metadata": { "tag": "baseline" },
4223 }),
4224 )
4225 .unwrap();
4226
4227 let rows = find_with_store(
4229 &store,
4230 FindQuery {
4231 pkg: Some(pkg.to_string()),
4232 where_: Some(where_from(json!({
4233 "metadata": { "tag": { "contains": "experiment" } },
4234 }))),
4235 ..Default::default()
4236 },
4237 )
4238 .unwrap();
4239 assert_eq!(rows.len(), 2);
4240
4241 let rows = find_with_store(
4243 &store,
4244 FindQuery {
4245 pkg: Some(pkg.to_string()),
4246 where_: Some(where_from(json!({
4247 "model": { "id": { "starts_with": "claude-opus" } },
4248 }))),
4249 ..Default::default()
4250 },
4251 )
4252 .unwrap();
4253 assert_eq!(rows.len(), 1);
4254 assert!(rows[0].card_id.ends_with("_a"));
4255
4256 let rows = find_with_store(
4258 &store,
4259 FindQuery {
4260 pkg: Some(pkg.to_string()),
4261 where_: Some(where_from(json!({
4262 "metadata": { "missing_field": { "contains": "x" } },
4263 }))),
4264 ..Default::default()
4265 },
4266 )
4267 .unwrap();
4268 assert_eq!(rows.len(), 0);
4269
4270 let rows = find_with_store(
4272 &store,
4273 FindQuery {
4274 pkg: Some(pkg.to_string()),
4275 where_: Some(where_from(json!({
4276 "metadata": { "tag": { "starts_with": 42 } },
4277 }))),
4278 ..Default::default()
4279 },
4280 )
4281 .unwrap();
4282 assert_eq!(rows.len(), 0);
4283 }
4284
4285 #[test]
4286 fn where_missing_field_ne_is_true() {
4287 let tmp = tempfile::tempdir().unwrap();
4288 let store = FileCardStore::new(tmp.path().to_path_buf());
4289 let pkg = "where_missing_ne_pkg";
4290 create_with_store(
4291 &store,
4292 json!({
4293 "card_id": format!("{pkg}_x"),
4294 "pkg": { "name": pkg },
4295 }),
4296 )
4297 .unwrap();
4298
4299 let rows = find_with_store(
4300 &store,
4301 FindQuery {
4302 pkg: Some(pkg.to_string()),
4303 where_: Some(where_from(json!({
4304 "strategy_params": { "temperature": { "ne": 0.5 } },
4305 }))),
4306 ..Default::default()
4307 },
4308 )
4309 .unwrap();
4310 assert_eq!(rows.len(), 1, "missing field is ne to anything");
4311 }
4312
4313 fn create_child(
4317 store: &FileCardStore,
4318 pkg: &str,
4319 suffix: &str,
4320 parent_id: &str,
4321 relation: &str,
4322 ) -> String {
4323 let (id, _) = create_with_store(
4324 store,
4325 json!({
4326 "card_id": format!("{pkg}_{suffix}"),
4327 "pkg": { "name": pkg },
4328 "stats": { "pass_rate": 0.5 },
4329 "metadata": {
4330 "prior_card_id": parent_id,
4331 "prior_relation": relation,
4332 },
4333 }),
4334 )
4335 .unwrap();
4336 id
4337 }
4338
4339 #[test]
4340 fn lineage_up_walks_prior_card_id_chain() {
4341 let tmp = tempfile::tempdir().unwrap();
4342 let store = FileCardStore::new(tmp.path().to_path_buf());
4343 let pkg = "lineage_up_pkg";
4344 let (a, _) = create_with_store(
4346 &store,
4347 json!({
4348 "card_id": format!("{pkg}_a"),
4349 "pkg": { "name": pkg },
4350 }),
4351 )
4352 .unwrap();
4353 let b = create_child(&store, pkg, "b", &a, "rerun_of");
4354 let c = create_child(&store, pkg, "c", &b, "rerun_of");
4355
4356 let res = lineage_with_store(
4357 &store,
4358 LineageQuery {
4359 card_id: c.clone(),
4360 direction: LineageDirection::Up,
4361 depth: None,
4362 include_stats: false,
4363 relation_filter: None,
4364 },
4365 )
4366 .unwrap()
4367 .expect("lineage result");
4368
4369 assert_eq!(res.root, c);
4370 assert_eq!(res.nodes.len(), 3, "root + 2 ancestors");
4371 assert_eq!(res.nodes[0].card_id, c);
4372 assert_eq!(res.nodes[0].depth, 0);
4373 assert_eq!(res.nodes[1].card_id, b);
4374 assert_eq!(res.nodes[1].depth, -1);
4375 assert_eq!(res.nodes[2].card_id, a);
4376 assert_eq!(res.nodes[2].depth, -2);
4377 assert_eq!(res.edges.len(), 2);
4378 assert!(!res.truncated);
4379 }
4380
4381 #[test]
4382 fn lineage_down_walks_descendants_breadth_first() {
4383 let tmp = tempfile::tempdir().unwrap();
4384 let store = FileCardStore::new(tmp.path().to_path_buf());
4385 let pkg = "lineage_down_pkg";
4386 let (a, _) = create_with_store(
4388 &store,
4389 json!({
4390 "card_id": format!("{pkg}_a"),
4391 "pkg": { "name": pkg },
4392 }),
4393 )
4394 .unwrap();
4395 let _b = create_child(&store, pkg, "b", &a, "sweep_variant");
4396 let c = create_child(&store, pkg, "c", &a, "sweep_variant");
4397 let _d = create_child(&store, pkg, "d", &c, "rerun_of");
4398
4399 let res = lineage_with_store(
4400 &store,
4401 LineageQuery {
4402 card_id: a.clone(),
4403 direction: LineageDirection::Down,
4404 depth: None,
4405 include_stats: false,
4406 relation_filter: None,
4407 },
4408 )
4409 .unwrap()
4410 .expect("lineage result");
4411
4412 assert_eq!(res.nodes.len(), 4);
4414 assert_eq!(res.edges.len(), 3);
4415 assert!(!res.truncated);
4416 }
4417
4418 #[test]
4419 fn lineage_depth_truncation_sets_flag() {
4420 let tmp = tempfile::tempdir().unwrap();
4421 let store = FileCardStore::new(tmp.path().to_path_buf());
4422 let pkg = "lineage_depth_pkg";
4423 let (a, _) = create_with_store(
4424 &store,
4425 json!({
4426 "card_id": format!("{pkg}_a"),
4427 "pkg": { "name": pkg },
4428 }),
4429 )
4430 .unwrap();
4431 let b = create_child(&store, pkg, "b", &a, "rerun_of");
4432 let _c = create_child(&store, pkg, "c", &b, "rerun_of");
4433
4434 let res = lineage_with_store(
4435 &store,
4436 LineageQuery {
4437 card_id: a,
4438 direction: LineageDirection::Down,
4439 depth: Some(1),
4440 include_stats: false,
4441 relation_filter: None,
4442 },
4443 )
4444 .unwrap()
4445 .unwrap();
4446 assert_eq!(res.nodes.len(), 2, "root + 1 level");
4447 assert!(res.truncated, "should be truncated at depth=1");
4448 }
4449
4450 #[test]
4451 fn lineage_relation_filter_skips_unlisted() {
4452 let tmp = tempfile::tempdir().unwrap();
4453 let store = FileCardStore::new(tmp.path().to_path_buf());
4454 let pkg = "lineage_filter_pkg";
4455 let (a, _) = create_with_store(
4456 &store,
4457 json!({
4458 "card_id": format!("{pkg}_a"),
4459 "pkg": { "name": pkg },
4460 }),
4461 )
4462 .unwrap();
4463 let _b = create_child(&store, pkg, "b", &a, "sweep_variant");
4464 let _c = create_child(&store, pkg, "c", &a, "rerun_of");
4465
4466 let res = lineage_with_store(
4467 &store,
4468 LineageQuery {
4469 card_id: a,
4470 direction: LineageDirection::Down,
4471 depth: None,
4472 include_stats: false,
4473 relation_filter: Some(vec!["sweep_variant".to_string()]),
4474 },
4475 )
4476 .unwrap()
4477 .unwrap();
4478 assert_eq!(res.nodes.len(), 2, "root + only sweep_variant child");
4479 assert_eq!(res.edges[0].relation.as_deref(), Some("sweep_variant"));
4480 }
4481
4482 #[test]
4483 fn lineage_missing_card_returns_none() {
4484 let tmp = tempfile::tempdir().unwrap();
4485 let store = FileCardStore::new(tmp.path().to_path_buf());
4486 let res = lineage_with_store(
4487 &store,
4488 LineageQuery {
4489 card_id: "nonexistent_card_id_xyz".into(),
4490 direction: LineageDirection::Up,
4491 depth: None,
4492 include_stats: false,
4493 relation_filter: None,
4494 },
4495 )
4496 .unwrap();
4497 assert!(res.is_none());
4498 }
4499
4500 #[test]
4506 fn write_and_read_samples_roundtrip() {
4507 let tmp = tempfile::tempdir().unwrap();
4508 let store = FileCardStore::new(tmp.path().to_path_buf());
4509 let (id, _) = create_with_store(
4510 &store,
4511 json!({
4512 "pkg": { "name": "roundtrip_pkg" },
4513 "stats": { "pass_rate": 0.5 }
4514 }),
4515 )
4516 .unwrap();
4517
4518 let samples = vec![
4519 json!({ "case": "c0", "passed": true, "score": 1.0 }),
4520 json!({ "case": "c1", "passed": false, "score": 0.0 }),
4521 json!({ "case": "c2", "passed": true, "score": 0.75 }),
4522 ];
4523 let path = write_samples_with_store(&store, &id, samples.clone()).unwrap();
4524 assert!(path.exists());
4525 assert!(path.to_string_lossy().ends_with(".samples.jsonl"));
4526
4527 let got = read_samples_with_store(&store, &id, SamplesQuery::default()).unwrap();
4528 assert_eq!(got.len(), 3);
4529 assert_eq!(got[0]["case"], json!("c0"));
4530 assert_eq!(got[2]["score"], json!(0.75));
4531
4532 let slice = read_samples_with_store(
4534 &store,
4535 &id,
4536 SamplesQuery {
4537 offset: 1,
4538 limit: Some(1),
4539 where_: None,
4540 },
4541 )
4542 .unwrap();
4543 assert_eq!(slice.len(), 1);
4544 assert_eq!(slice[0]["case"], json!("c1"));
4545 }
4546
4547 #[test]
4548 fn write_samples_is_write_once() {
4549 let tmp = tempfile::tempdir().unwrap();
4550 let store = FileCardStore::new(tmp.path().to_path_buf());
4551 let (id, _) =
4552 create_with_store(&store, json!({ "pkg": { "name": "write_once_pkg" } })).unwrap();
4553 write_samples_with_store(&store, &id, vec![json!({ "x": 1 })]).unwrap();
4554 let err = write_samples_with_store(&store, &id, vec![json!({ "x": 2 })]).unwrap_err();
4555 assert!(err.contains("already exist"), "got: {err}");
4556 }
4557
4558 #[test]
4570 fn read_samples_empty_when_absent() {
4571 let tmp = tempfile::tempdir().unwrap();
4572 let store = FileCardStore::new(tmp.path().to_path_buf());
4573 let (id, _) = create_with_store(
4574 &store,
4575 json!({ "pkg": { "name": "read_samples_empty_pkg" } }),
4576 )
4577 .unwrap();
4578 let got = read_samples_with_store(&store, &id, SamplesQuery::default()).unwrap();
4579 assert!(got.is_empty());
4580 }
4581
4582 #[test]
4583 fn read_samples_where_filters_rows() {
4584 let tmp = tempfile::tempdir().unwrap();
4585 let store = FileCardStore::new(tmp.path().to_path_buf());
4586 let (id, _) =
4587 create_with_store(&store, json!({ "pkg": { "name": "where_filter_pkg" } })).unwrap();
4588 write_samples_with_store(
4589 &store,
4590 &id,
4591 vec![
4592 json!({ "case": "c0", "passed": true, "score": 1.0 }),
4593 json!({ "case": "c1", "passed": false, "score": 0.0 }),
4594 json!({ "case": "c2", "passed": true, "score": 0.25 }),
4595 json!({ "case": "c3", "passed": true, "score": 0.75 }),
4596 json!({ "case": "c4", "passed": false, "score": 0.5 }),
4597 ],
4598 )
4599 .unwrap();
4600
4601 let pred = parse_where(&json!({ "passed": true })).unwrap();
4603 let got = read_samples_with_store(
4604 &store,
4605 &id,
4606 SamplesQuery {
4607 offset: 0,
4608 limit: None,
4609 where_: Some(pred),
4610 },
4611 )
4612 .unwrap();
4613 assert_eq!(got.len(), 3);
4614 assert_eq!(got[0]["case"], json!("c0"));
4615 assert_eq!(got[1]["case"], json!("c2"));
4616 assert_eq!(got[2]["case"], json!("c3"));
4617
4618 let pred = parse_where(&json!({ "score": { "gte": 0.5 } })).unwrap();
4620 let got = read_samples_with_store(
4621 &store,
4622 &id,
4623 SamplesQuery {
4624 offset: 0,
4625 limit: None,
4626 where_: Some(pred),
4627 },
4628 )
4629 .unwrap();
4630 assert_eq!(got.len(), 3);
4631 assert_eq!(got[0]["case"], json!("c0"));
4632 assert_eq!(got[1]["case"], json!("c3"));
4633 assert_eq!(got[2]["case"], json!("c4"));
4634
4635 let pred = parse_where(&json!({ "passed": true })).unwrap();
4637 let slice = read_samples_with_store(
4638 &store,
4639 &id,
4640 SamplesQuery {
4641 offset: 1,
4642 limit: Some(1),
4643 where_: Some(pred),
4644 },
4645 )
4646 .unwrap();
4647 assert_eq!(slice.len(), 1);
4648 assert_eq!(slice[0]["case"], json!("c2"));
4649 }
4650
4651 #[test]
4652 fn get_by_alias_roundtrip() {
4653 let tmp = tempfile::tempdir().unwrap();
4654 let store = FileCardStore::new(tmp.path().to_path_buf());
4655 let pkg = "get_by_alias_pkg";
4656 let (id, _) = create_with_store(
4657 &store,
4658 json!({
4659 "pkg": { "name": pkg },
4660 "stats": { "pass_rate": 0.85 }
4661 }),
4662 )
4663 .unwrap();
4664
4665 let alias_name = "best_by_alias";
4666 alias_set_with_store(&store, alias_name, &id, Some(pkg), None).unwrap();
4667
4668 let card = get_by_alias_with_store(&store, alias_name)
4669 .unwrap()
4670 .unwrap();
4671 assert_eq!(card["card_id"], json!(id));
4672 assert_eq!(card["stats"]["pass_rate"], json!(0.85));
4673
4674 assert!(get_by_alias_with_store(&store, "nonexistent_alias_xyz")
4675 .unwrap()
4676 .is_none());
4677 }
4678
4679 #[test]
4680 fn samples_errors_on_missing_card() {
4681 let tmp = tempfile::tempdir().unwrap();
4682 let store = FileCardStore::new(tmp.path().to_path_buf());
4683 let err = write_samples_with_store(&store, "does_not_exist_xyz_samples", vec![json!({})])
4684 .unwrap_err();
4685 assert!(err.contains("not found"));
4686 }
4687
4688 #[test]
4691 fn import_from_dir_copies_cards() {
4692 let pkg = "import_copies_pkg";
4693 let src_tmp = tempfile::tempdir().unwrap();
4694 let store_tmp = tempfile::tempdir().unwrap();
4695 let store = FileCardStore::new(store_tmp.path().to_path_buf());
4696
4697 let card_id = format!("{pkg}_imported");
4699 let card_content = format!(
4700 "schema_version = \"{SCHEMA_VERSION}\"\ncard_id = \"{card_id}\"\npkg = \"{pkg}\"\n"
4701 );
4702 fs::write(
4703 src_tmp.path().join(format!("{card_id}.toml")),
4704 &card_content,
4705 )
4706 .unwrap();
4707
4708 fs::write(
4710 src_tmp.path().join(format!("{card_id}.samples.jsonl")),
4711 "{\"case\":\"c0\"}\n",
4712 )
4713 .unwrap();
4714
4715 let (imported, skipped) = import_from_dir_with_store(&store, src_tmp.path(), pkg).unwrap();
4716 assert_eq!(imported, vec![card_id.clone()]);
4717 assert!(skipped.is_empty());
4718
4719 let got = get_with_store(&store, &card_id).unwrap().unwrap();
4721 assert_eq!(got["card_id"], json!(card_id));
4722
4723 let samples = read_samples_with_store(&store, &card_id, SamplesQuery::default()).unwrap();
4725 assert_eq!(samples.len(), 1);
4726 }
4727
4728 #[test]
4729 fn import_from_dir_skips_existing() {
4730 let store_tmp = tempfile::tempdir().unwrap();
4731 let store = FileCardStore::new(store_tmp.path().to_path_buf());
4732 let pkg = "import_skips_existing_pkg";
4733 let (existing_id, _) = create_with_store(
4735 &store,
4736 json!({
4737 "pkg": { "name": pkg },
4738 "stats": { "pass_rate": 0.5 }
4739 }),
4740 )
4741 .unwrap();
4742
4743 let src_tmp = tempfile::tempdir().unwrap();
4745 let card_content = format!(
4746 "schema_version = \"{SCHEMA_VERSION}\"\ncard_id = \"{existing_id}\"\npkg = \"{pkg}\"\n"
4747 );
4748 fs::write(
4749 src_tmp.path().join(format!("{existing_id}.toml")),
4750 &card_content,
4751 )
4752 .unwrap();
4753
4754 let (imported, skipped) = import_from_dir_with_store(&store, src_tmp.path(), pkg).unwrap();
4755 assert!(imported.is_empty());
4756 assert_eq!(skipped, vec![existing_id.clone()]);
4757
4758 let got = get_with_store(&store, &existing_id).unwrap().unwrap();
4760 assert_eq!(got["stats"]["pass_rate"], json!(0.5));
4761 }
4762
4763 #[test]
4764 fn import_from_dir_skips_non_card_toml() {
4765 let store_tmp = tempfile::tempdir().unwrap();
4766 let store = FileCardStore::new(store_tmp.path().to_path_buf());
4767 let pkg = "import_skips_non_card_pkg";
4768 let src_tmp = tempfile::tempdir().unwrap();
4769
4770 fs::write(
4772 src_tmp.path().join("not_a_card.toml"),
4773 "title = \"hello\"\n",
4774 )
4775 .unwrap();
4776
4777 let (imported, skipped) = import_from_dir_with_store(&store, src_tmp.path(), pkg).unwrap();
4778 assert!(imported.is_empty());
4779 assert!(skipped.is_empty());
4780 }
4781
4782 #[test]
4788 fn custom_root_file_store_roundtrip() {
4789 let tmp = tempfile::tempdir().unwrap();
4790 let store = FileCardStore::new(tmp.path().to_path_buf());
4791 let pkg = "custom_root_pkg";
4792
4793 let (id, path) = create_with_store(
4795 &store,
4796 json!({
4797 "pkg": { "name": pkg },
4798 "model": { "id": "gpt-test" },
4799 }),
4800 )
4801 .unwrap();
4802 assert!(path.starts_with(tmp.path()));
4803 assert!(path.ends_with(format!("{id}.toml")));
4804
4805 let card = get_with_store(&store, &id).unwrap().expect("card exists");
4806 assert_eq!(
4807 card.get("card_id").and_then(|v| v.as_str()),
4808 Some(id.as_str())
4809 );
4810
4811 let rows = list_with_store(&store, Some(pkg)).unwrap();
4812 assert_eq!(rows.len(), 1);
4813 assert_eq!(rows[0].card_id, id);
4814
4815 let other_tmp = tempfile::tempdir().unwrap();
4817 let other_store = FileCardStore::new(other_tmp.path().to_path_buf());
4818 let default_rows = list_with_store(&other_store, Some(pkg)).unwrap();
4819 assert!(default_rows.iter().all(|r| r.card_id != id));
4820
4821 alias_set_with_store(&store, "alpha", &id, Some(pkg), None).unwrap();
4823 let via_alias = get_by_alias_with_store(&store, "alpha")
4824 .unwrap()
4825 .expect("alias resolves");
4826 assert_eq!(
4827 via_alias.get("card_id").and_then(|v| v.as_str()),
4828 Some(id.as_str())
4829 );
4830
4831 let samples_path =
4833 write_samples_with_store(&store, &id, vec![json!({ "case": "a", "pass": true })])
4834 .unwrap();
4835 assert!(samples_path.starts_with(tmp.path()));
4836 let back = read_samples_with_store(&store, &id, SamplesQuery::default()).unwrap();
4837 assert_eq!(back.len(), 1);
4838 assert_eq!(back[0].get("case").and_then(|v| v.as_str()), Some("a"));
4839 }
4840
4841 use std::sync::atomic::AtomicUsize;
4846 use std::sync::Barrier;
4847
4848 fn env_lock() -> &'static Mutex<()> {
4851 static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
4852 LOCK.get_or_init(|| Mutex::new(()))
4853 }
4854
4855 struct BusTestOwnerGuard;
4861 impl Drop for BusTestOwnerGuard {
4862 fn drop(&mut self) {
4863 INSIDE_BUS_TEST.with(|flag| flag.set(false));
4864 }
4865 }
4866
4867 fn with_bus_subscribers<F>(subs: Vec<Arc<dyn CardSubscriber>>, f: F)
4882 where
4883 F: FnOnce(&'static CardEventBus),
4884 {
4885 let _guard = bus_test_gate().lock().unwrap_or_else(|p| p.into_inner());
4888 INSIDE_BUS_TEST.with(|flag| flag.set(true));
4892 let _owner = BusTestOwnerGuard;
4893 let bus = event_bus();
4894 bus.reset_stats_for_test();
4895 bus.replace_subscribers_for_test(subs);
4896 f(bus);
4897 bus.replace_subscribers_for_test(Vec::new());
4899 bus.reset_stats_for_test();
4900 }
4903
4904 struct MockSubscriber {
4906 uri: String,
4907 events: Mutex<Vec<CardEvent>>,
4908 calls: AtomicUsize,
4909 }
4910
4911 impl MockSubscriber {
4912 fn new(uri: &str) -> Arc<Self> {
4913 Arc::new(Self {
4914 uri: uri.to_string(),
4915 events: Mutex::new(Vec::new()),
4916 calls: AtomicUsize::new(0),
4917 })
4918 }
4919 fn call_count(&self) -> usize {
4920 self.calls.load(Ordering::SeqCst)
4921 }
4922 }
4923
4924 impl CardSubscriber for MockSubscriber {
4925 fn on_event(&self, ev: &CardEvent) -> Result<(), String> {
4926 self.calls.fetch_add(1, Ordering::SeqCst);
4927 self.events
4928 .lock()
4929 .unwrap_or_else(|p| p.into_inner())
4930 .push(ev.clone());
4931 Ok(())
4932 }
4933 fn describe(&self) -> String {
4934 self.uri.clone()
4935 }
4936 }
4937
4938 #[test]
4941 fn bus_is_process_singleton() {
4942 let a = event_bus() as *const CardEventBus;
4943 let b = event_bus() as *const CardEventBus;
4944 assert_eq!(a, b, "event_bus() must return the same singleton pointer");
4945 }
4946
4947 #[test]
4948 fn publish_with_no_subscribers_is_noop() {
4949 with_bus_subscribers(Vec::new(), |_bus| {
4950 publish(CardEvent::Created {
4952 pkg: "pkg".into(),
4953 card_id: "id".into(),
4954 toml_text: "x = 1\n".into(),
4955 });
4956 });
4957 }
4958
4959 #[test]
4960 fn init_event_bus_is_idempotent() {
4961 init_event_bus();
4962 init_event_bus();
4963 init_event_bus();
4964 }
4966
4967 #[test]
4970 fn fanout_mirrors_create() {
4971 let primary = tempfile::tempdir().unwrap();
4972 let sub_a = tempfile::tempdir().unwrap();
4973 let sub_b = tempfile::tempdir().unwrap();
4974 let fa = Arc::new(FileCardSubscriber::new(sub_a.path().to_path_buf()));
4975 let fb = Arc::new(FileCardSubscriber::new(sub_b.path().to_path_buf()));
4976 with_bus_subscribers(vec![fa.clone(), fb.clone()], |_bus| {
4977 let store = FileCardStore::new(primary.path().to_path_buf());
4978 let (id, path) =
4979 create_with_store(&store, json!({ "pkg": { "name": "fanout_create_pkg" } }))
4980 .unwrap();
4981 assert!(path.exists());
4982 let primary_text = fs::read_to_string(&path).unwrap();
4983 let a_path = sub_a
4984 .path()
4985 .join("fanout_create_pkg")
4986 .join(format!("{id}.toml"));
4987 let b_path = sub_b
4988 .path()
4989 .join("fanout_create_pkg")
4990 .join(format!("{id}.toml"));
4991 assert!(a_path.exists(), "subscriber A missing card");
4992 assert!(b_path.exists(), "subscriber B missing card");
4993 assert_eq!(fs::read_to_string(&a_path).unwrap(), primary_text);
4994 assert_eq!(fs::read_to_string(&b_path).unwrap(), primary_text);
4995 });
4996 }
4997
4998 #[test]
4999 fn fanout_mirrors_append() {
5000 let primary = tempfile::tempdir().unwrap();
5001 let sub = tempfile::tempdir().unwrap();
5002 let fs_sub = Arc::new(FileCardSubscriber::new(sub.path().to_path_buf()));
5003 with_bus_subscribers(vec![fs_sub.clone()], |_bus| {
5004 let store = FileCardStore::new(primary.path().to_path_buf());
5005 let (id, _) =
5006 create_with_store(&store, json!({ "pkg": { "name": "fanout_append_pkg" } }))
5007 .unwrap();
5008 append_with_store(&store, &id, json!({ "extra_key": 42 })).unwrap();
5010 let sub_path = sub
5011 .path()
5012 .join("fanout_append_pkg")
5013 .join(format!("{id}.toml"));
5014 let text = fs::read_to_string(&sub_path).unwrap();
5015 assert!(text.contains("extra_key"), "append must mirror new key");
5016 });
5017 }
5018
5019 #[test]
5020 fn fanout_mirrors_samples() {
5021 let primary = tempfile::tempdir().unwrap();
5022 let sub = tempfile::tempdir().unwrap();
5023 let fs_sub = Arc::new(FileCardSubscriber::new(sub.path().to_path_buf()));
5024 with_bus_subscribers(vec![fs_sub.clone()], |_bus| {
5025 let store = FileCardStore::new(primary.path().to_path_buf());
5026 let (id, _) =
5027 create_with_store(&store, json!({ "pkg": { "name": "fanout_samples_pkg" } }))
5028 .unwrap();
5029 write_samples_with_store(&store, &id, vec![json!({ "case": "c0" })]).unwrap();
5030 let sub_path = sub
5031 .path()
5032 .join("fanout_samples_pkg")
5033 .join(format!("{id}.samples.jsonl"));
5034 let text = fs::read_to_string(&sub_path).unwrap();
5035 assert!(text.contains("\"case\":\"c0\""));
5036 });
5037 }
5038
5039 #[test]
5040 fn fanout_mirrors_aliases() {
5041 let primary = tempfile::tempdir().unwrap();
5042 let sub = tempfile::tempdir().unwrap();
5043 let fs_sub = Arc::new(FileCardSubscriber::new(sub.path().to_path_buf()));
5044 with_bus_subscribers(vec![fs_sub.clone()], |_bus| {
5045 let store = FileCardStore::new(primary.path().to_path_buf());
5046 let (id, _) =
5047 create_with_store(&store, json!({ "pkg": { "name": "fanout_alias_pkg" } }))
5048 .unwrap();
5049 alias_set_with_store(&store, "myalias", &id, Some("fanout_alias_pkg"), None).unwrap();
5050 let sub_aliases = sub.path().join("_aliases.toml");
5051 assert!(sub_aliases.exists(), "subscriber must receive aliases file");
5052 let text = fs::read_to_string(&sub_aliases).unwrap();
5053 assert!(text.contains("myalias"));
5054 });
5055 }
5056
5057 #[test]
5058 fn fanout_mirrors_import_from_dir_cards() {
5059 let primary = tempfile::tempdir().unwrap();
5060 let sub = tempfile::tempdir().unwrap();
5061 let src = tempfile::tempdir().unwrap();
5062
5063 let src_card = src.path().join("card_x.toml");
5065 let toml_body = format!(
5066 "schema_version = \"{SCHEMA_VERSION}\"\ncard_id = \"card_x\"\ncreated_at = \"2026-01-01T00:00:00Z\"\n[pkg]\nname = \"fanout_import_pkg\"\n"
5067 );
5068 fs::write(&src_card, &toml_body).unwrap();
5069
5070 let fs_sub = Arc::new(FileCardSubscriber::new(sub.path().to_path_buf()));
5071 with_bus_subscribers(vec![fs_sub.clone()], |_bus| {
5072 let store = FileCardStore::new(primary.path().to_path_buf());
5073 let (imported, _skipped) =
5074 import_from_dir_with_store(&store, src.path(), "fanout_import_pkg").unwrap();
5075 assert_eq!(imported, vec!["card_x".to_string()]);
5076
5077 let sub_card = sub.path().join("fanout_import_pkg").join("card_x.toml");
5078 assert!(sub_card.exists(), "imported card must be mirrored");
5079 });
5080 }
5081
5082 #[test]
5083 fn fanout_mirrors_import_from_dir_samples() {
5084 let primary = tempfile::tempdir().unwrap();
5085 let sub = tempfile::tempdir().unwrap();
5086 let src = tempfile::tempdir().unwrap();
5087
5088 let toml_body = format!(
5089 "schema_version = \"{SCHEMA_VERSION}\"\ncard_id = \"card_y\"\ncreated_at = \"2026-01-01T00:00:00Z\"\n[pkg]\nname = \"fanout_import_samples_pkg\"\n"
5090 );
5091 fs::write(src.path().join("card_y.toml"), &toml_body).unwrap();
5092 fs::write(
5093 src.path().join("card_y.samples.jsonl"),
5094 "{\"case\":\"c0\"}\n",
5095 )
5096 .unwrap();
5097
5098 let fs_sub = Arc::new(FileCardSubscriber::new(sub.path().to_path_buf()));
5099 with_bus_subscribers(vec![fs_sub.clone()], |_bus| {
5100 let store = FileCardStore::new(primary.path().to_path_buf());
5101 let (imported, _) =
5102 import_from_dir_with_store(&store, src.path(), "fanout_import_samples_pkg")
5103 .unwrap();
5104 assert_eq!(imported, vec!["card_y".to_string()]);
5105
5106 let sub_samples = sub
5107 .path()
5108 .join("fanout_import_samples_pkg")
5109 .join("card_y.samples.jsonl");
5110 assert!(sub_samples.exists(), "imported samples must be mirrored");
5111 let text = fs::read_to_string(&sub_samples).unwrap();
5112 assert!(text.contains("c0"));
5113 });
5114 }
5115
5116 #[test]
5117 fn with_store_direct_call_still_publishes() {
5118 let primary = tempfile::tempdir().unwrap();
5119 let mock = MockSubscriber::new("mock://direct");
5120 with_bus_subscribers(vec![mock.clone() as Arc<dyn CardSubscriber>], |_bus| {
5121 let store = FileCardStore::new(primary.path().to_path_buf());
5122 create_with_store(&store, json!({ "pkg": { "name": "direct_call_pkg" } })).unwrap();
5123 assert_eq!(mock.call_count(), 1, "direct _with_store call must publish");
5124 });
5125 }
5126
5127 #[test]
5128 fn subscriber_appended_missing_card_warns() {
5129 let primary = tempfile::tempdir().unwrap();
5130 let sub = tempfile::tempdir().unwrap();
5131 let fs_sub = Arc::new(FileCardSubscriber::new(sub.path().to_path_buf()));
5132 with_bus_subscribers(vec![fs_sub.clone()], |bus| {
5133 let store = FileCardStore::new(primary.path().to_path_buf());
5134 bus.replace_subscribers_for_test(Vec::new());
5137 let (id, _) =
5138 create_with_store(&store, json!({ "pkg": { "name": "missing_append_pkg" } }))
5139 .unwrap();
5140 bus.replace_subscribers_for_test(vec![fs_sub.clone()]);
5142
5143 append_with_store(&store, &id, json!({ "k": 1 })).unwrap();
5146
5147 let snap = bus.stats().snapshot();
5148 let row = snap
5149 .iter()
5150 .find(|r| r.sink == fs_sub.describe())
5151 .expect("subscriber row exists");
5152 let err_total: u64 = row.err.values().sum();
5153 assert!(err_total >= 1, "subscriber append err must be recorded");
5154 assert!(row.last_error.is_some());
5155 });
5156 }
5157
5158 #[test]
5159 fn subscriber_failure_preserves_primary() {
5160 struct FailingSubscriber;
5161 impl CardSubscriber for FailingSubscriber {
5162 fn on_event(&self, _ev: &CardEvent) -> Result<(), String> {
5163 Err("synthetic failure".into())
5164 }
5165 fn describe(&self) -> String {
5166 "mock://failing".into()
5167 }
5168 }
5169 let primary = tempfile::tempdir().unwrap();
5170 with_bus_subscribers(
5171 vec![Arc::new(FailingSubscriber) as Arc<dyn CardSubscriber>],
5172 |bus| {
5173 let store = FileCardStore::new(primary.path().to_path_buf());
5174 let (_id, path) =
5176 create_with_store(&store, json!({ "pkg": { "name": "preserve_primary_pkg" } }))
5177 .unwrap();
5178 assert!(path.exists());
5179 let snap = bus.stats().snapshot();
5180 let row = snap
5181 .iter()
5182 .find(|r| r.sink == "mock://failing")
5183 .expect("row exists");
5184 let err_total: u64 = row.err.values().sum();
5185 assert!(err_total >= 1);
5186 },
5187 );
5188 }
5189
5190 #[test]
5193 fn stats_counts_ok() {
5194 let primary = tempfile::tempdir().unwrap();
5195 let mock = MockSubscriber::new("mock://stats-ok");
5196 with_bus_subscribers(vec![mock.clone() as Arc<dyn CardSubscriber>], |bus| {
5197 let store = FileCardStore::new(primary.path().to_path_buf());
5198 for i in 0..3 {
5199 create_with_store(
5200 &store,
5201 json!({
5202 "card_id": format!("stats_ok_{i}"),
5203 "pkg": { "name": "stats_ok_pkg" },
5204 }),
5205 )
5206 .unwrap();
5207 }
5208 let snap = bus.stats().snapshot();
5209 let row = snap
5210 .iter()
5211 .find(|r| r.sink == "mock://stats-ok")
5212 .expect("row");
5213 assert_eq!(row.ok.get("created").copied().unwrap_or(0), 3);
5214 assert_eq!(row.err.get("created").copied().unwrap_or(0), 0);
5215 for k in ["created", "appended", "samples", "aliases"] {
5217 assert!(row.ok.contains_key(k), "ok.{k} must be present");
5218 assert!(row.err.contains_key(k), "err.{k} must be present");
5219 }
5220 assert!(row.last_error.is_none());
5221 });
5222 }
5223
5224 #[test]
5225 fn stats_counts_err_with_last_error() {
5226 struct FailingSubscriber;
5227 impl CardSubscriber for FailingSubscriber {
5228 fn on_event(&self, _ev: &CardEvent) -> Result<(), String> {
5229 Err("synthetic create failure".into())
5230 }
5231 fn describe(&self) -> String {
5232 "mock://stats-err".into()
5233 }
5234 }
5235 let primary = tempfile::tempdir().unwrap();
5236 with_bus_subscribers(
5237 vec![Arc::new(FailingSubscriber) as Arc<dyn CardSubscriber>],
5238 |bus| {
5239 let store = FileCardStore::new(primary.path().to_path_buf());
5240 create_with_store(&store, json!({ "pkg": { "name": "stats_err_pkg" } })).unwrap();
5241 let snap = bus.stats().snapshot();
5242 let row = snap
5243 .iter()
5244 .find(|r| r.sink == "mock://stats-err")
5245 .expect("row");
5246 assert_eq!(row.err.get("created").copied().unwrap_or(0), 1);
5247 let le = row.last_error.as_ref().expect("last_error set");
5248 assert!(!le.msg.is_empty(), "last_error.msg must be non-empty");
5249 assert_eq!(le.kind, CardEventKind::Created);
5250 assert!(le.ts_ms > 0, "last_error.ts_ms must be populated");
5251 },
5252 );
5253 }
5254
5255 #[test]
5256 fn stats_per_subscriber_isolated() {
5257 struct FailingSubscriber;
5258 impl CardSubscriber for FailingSubscriber {
5259 fn on_event(&self, _ev: &CardEvent) -> Result<(), String> {
5260 Err("sub1 fails".into())
5261 }
5262 fn describe(&self) -> String {
5263 "mock://sub1-fail".into()
5264 }
5265 }
5266 let primary = tempfile::tempdir().unwrap();
5267 let mock_ok = MockSubscriber::new("mock://sub2-ok");
5268 let subs: Vec<Arc<dyn CardSubscriber>> = vec![
5269 Arc::new(FailingSubscriber) as Arc<dyn CardSubscriber>,
5270 mock_ok.clone() as Arc<dyn CardSubscriber>,
5271 ];
5272 with_bus_subscribers(subs, |bus| {
5273 let store = FileCardStore::new(primary.path().to_path_buf());
5274 create_with_store(&store, json!({ "pkg": { "name": "isolated_pkg" } })).unwrap();
5275 let snap = bus.stats().snapshot();
5276 let r1 = snap
5277 .iter()
5278 .find(|r| r.sink == "mock://sub1-fail")
5279 .expect("sub1 row");
5280 let r2 = snap
5281 .iter()
5282 .find(|r| r.sink == "mock://sub2-ok")
5283 .expect("sub2 row");
5284 assert_eq!(r1.err.get("created").copied().unwrap_or(0), 1);
5285 assert_eq!(r1.ok.get("created").copied().unwrap_or(0), 0);
5286 assert_eq!(r2.ok.get("created").copied().unwrap_or(0), 1);
5287 assert_eq!(r2.err.get("created").copied().unwrap_or(0), 0);
5288 assert!(r1.last_error.is_some());
5289 assert!(r2.last_error.is_none());
5290 });
5291 }
5292
5293 #[test]
5294 fn subscriber_stats_survive_multiple_calls() {
5295 let primary = tempfile::tempdir().unwrap();
5300 let mock = MockSubscriber::new("mock://stats-survive");
5301 with_bus_subscribers(vec![mock.clone() as Arc<dyn CardSubscriber>], |_bus| {
5302 let store = FileCardStore::new(primary.path().to_path_buf());
5303 for i in 0..3 {
5304 create_with_store(
5305 &store,
5306 json!({
5307 "card_id": format!("survive_{i}"),
5308 "pkg": { "name": "survive_pkg" },
5309 }),
5310 )
5311 .unwrap();
5312 }
5313 let snap = subscriber_stats_snapshot();
5316 let row = snap
5317 .iter()
5318 .find(|r| r.sink == "mock://stats-survive")
5319 .expect("row");
5320 assert_eq!(
5321 row.ok.get("created").copied().unwrap_or(0),
5322 3,
5323 "counters must accumulate across calls"
5324 );
5325 });
5326 }
5327
5328 #[test]
5329 fn stats_snapshot_serializes_with_all_kind_keys() {
5330 let primary = tempfile::tempdir().unwrap();
5332 let mock = MockSubscriber::new("mock://json-shape");
5333 with_bus_subscribers(vec![mock.clone() as Arc<dyn CardSubscriber>], |_bus| {
5334 let store = FileCardStore::new(primary.path().to_path_buf());
5335 create_with_store(&store, json!({ "pkg": { "name": "json_shape_pkg" } })).unwrap();
5336 let snap = subscriber_stats_snapshot();
5337 let json = serde_json::to_value(&snap).expect("serializable");
5338 let arr = json.as_array().expect("array");
5339 let row = arr
5340 .iter()
5341 .find(|r| r.get("sink").and_then(|s| s.as_str()) == Some("mock://json-shape"))
5342 .expect("row present in JSON");
5343 assert_eq!(row.get("sink").unwrap(), "mock://json-shape");
5344 for k in ["created", "appended", "samples", "aliases"] {
5345 assert!(row.pointer(&format!("/ok/{k}")).is_some(), "ok.{k} missing");
5346 assert!(
5347 row.pointer(&format!("/err/{k}")).is_some(),
5348 "err.{k} missing"
5349 );
5350 }
5351 assert!(row.get("last_error").is_some());
5352 });
5353 }
5354
5355 #[test]
5356 fn multi_process_tmp_unique_suffix() {
5357 let pid = process::id();
5364 let sample = format!("whatever.tmp.{pid}.0");
5365 let rest = sample.trim_start_matches("whatever.tmp.");
5367 let (pid_part, seq_part) = rest.split_once('.').expect("dotted form");
5368 assert!(pid_part.chars().all(|c| c.is_ascii_digit()));
5369 assert!(seq_part.chars().all(|c| c.is_ascii_digit()));
5370
5371 let dir = tempfile::tempdir().unwrap();
5374 let dest = dir.path().join("out.txt");
5375 atomic_write(&dest, b"hello").unwrap();
5376 assert_eq!(fs::read_to_string(&dest).unwrap(), "hello");
5377 }
5378
5379 #[cfg(unix)]
5382 #[test]
5383 fn describe_roundtrips_env_spec() {
5384 let dir = tempfile::tempdir().unwrap();
5385 let sub = FileCardSubscriber::new(dir.path().to_path_buf());
5386 let uri = sub.describe();
5387 assert!(uri.starts_with("file:///"), "unix uri must be file:///...");
5388 let parsed = parse_subscriber_spec(&uri).expect("round-trip parse");
5390 assert_eq!(parsed.describe(), uri);
5391 }
5392
5393 #[cfg(windows)]
5394 #[test]
5395 fn describe_roundtrips_env_spec_windows() {
5396 let dir = tempfile::tempdir().unwrap();
5397 let sub = FileCardSubscriber::new(dir.path().to_path_buf());
5398 let uri = sub.describe();
5399 assert!(
5400 uri.starts_with("file:///"),
5401 "windows uri must be file:///..."
5402 );
5403 let parsed = parse_subscriber_spec(&uri).expect("round-trip parse");
5404 assert_eq!(parsed.describe(), uri);
5405 }
5406
5407 #[test]
5408 fn env_empty_means_no_subscribers() {
5409 let _g = env_lock().lock().unwrap_or_else(|p| p.into_inner());
5412 let prev = std::env::var("ALC_CARD_SINKS").ok();
5413 unsafe {
5415 std::env::set_var("ALC_CARD_SINKS", "");
5416 }
5417 let subs = load_subscribers_from_env();
5418 assert!(subs.is_empty());
5419 unsafe {
5421 match prev {
5422 Some(v) => std::env::set_var("ALC_CARD_SINKS", v),
5423 None => std::env::remove_var("ALC_CARD_SINKS"),
5424 }
5425 }
5426 }
5427
5428 #[test]
5429 fn env_parse_rejects_bare_path() {
5430 assert!(parse_subscriber_spec("/foo/bar").is_none());
5431 }
5432
5433 #[test]
5434 fn env_parse_rejects_unknown_scheme() {
5435 assert!(parse_subscriber_spec("sqlite:///foo").is_none());
5436 assert!(parse_subscriber_spec("s3://bucket/foo").is_none());
5437 assert!(parse_subscriber_spec("http://example.com/x").is_none());
5438 }
5439
5440 #[test]
5441 fn env_parse_rejects_non_empty_authority() {
5442 assert!(parse_subscriber_spec("file://host/path").is_none());
5443 }
5444
5445 #[test]
5446 fn env_parse_rejects_missing_double_slash() {
5447 assert!(parse_subscriber_spec("file:/foo").is_none());
5448 assert!(parse_subscriber_spec("file:foo").is_none());
5449 }
5450
5451 #[cfg(unix)]
5452 #[test]
5453 fn env_parse_accepts_file_uri() {
5454 let sub = parse_subscriber_spec("file:///tmp/algocline-sinks-unit").expect("accepted");
5455 assert_eq!(sub.describe(), "file:///tmp/algocline-sinks-unit");
5456 }
5457
5458 #[cfg(windows)]
5459 #[test]
5460 fn env_parse_accepts_file_uri_windows() {
5461 let sub = parse_subscriber_spec("file:///C:/algocline-sinks-unit").expect("accepted");
5462 assert!(sub.describe().starts_with("file:///"));
5464 }
5465
5466 #[test]
5467 fn env_parse_splits_by_pipe() {
5468 let subs = parse_subscribers_from_str("file:///tmp/a|file:///tmp/b");
5469 assert_eq!(subs.len(), 2);
5470 assert_eq!(subs[0].describe(), "file:///tmp/a");
5471 assert_eq!(subs[1].describe(), "file:///tmp/b");
5472 }
5473
5474 #[test]
5475 fn env_parse_treats_colon_as_literal_path() {
5476 #[cfg(unix)]
5478 {
5479 let sub = parse_subscriber_spec("file:///tmp/a:b").expect("accepted");
5480 assert_eq!(sub.describe(), "file:///tmp/a:b");
5481 }
5482 #[cfg(windows)]
5483 {
5484 let sub = parse_subscriber_spec("file:///C:/a:b").expect("accepted");
5486 assert!(sub.describe().contains(":"));
5487 }
5488 }
5489
5490 #[test]
5491 fn env_parse_percent_decode_space() {
5492 #[cfg(unix)]
5493 {
5494 let sub = parse_subscriber_spec("file:///tmp/a%20b").expect("accepted");
5495 assert_eq!(sub.describe(), "file:///tmp/a b");
5496 }
5497 #[cfg(windows)]
5498 {
5499 let sub = parse_subscriber_spec("file:///C:/a%20b").expect("accepted");
5500 assert!(sub.describe().contains(' '));
5501 }
5502 }
5503
5504 #[test]
5505 fn env_parse_percent_decode_rejects_invalid_hex() {
5506 assert!(parse_subscriber_spec("file:///tmp/a%ZZb").is_none());
5507 }
5508
5509 #[test]
5510 fn env_parse_percent_decode_rejects_incomplete() {
5511 assert!(parse_subscriber_spec("file:///tmp/a%2").is_none());
5512 assert!(parse_subscriber_spec("file:///tmp/a%").is_none());
5513 }
5514
5515 #[test]
5516 fn env_parse_rejects_non_utf8() {
5517 assert!(parse_subscriber_spec("file:///tmp/%C3%28").is_none());
5526 }
5527
5528 #[test]
5529 fn env_parse_dedups_duplicate_uris() {
5530 let subs = parse_subscribers_from_str("file:///tmp/x|file:///tmp/x|file:///tmp/y");
5531 assert_eq!(subs.len(), 2);
5532 assert_eq!(subs[0].describe(), "file:///tmp/x");
5533 assert_eq!(subs[1].describe(), "file:///tmp/y");
5534 }
5535
5536 #[test]
5541 fn test_oncelock_init_race_single_winner() {
5542 let barrier = Arc::new(Barrier::new(8));
5545 let mut handles = Vec::new();
5546 for _ in 0..8 {
5547 let b = barrier.clone();
5548 handles.push(std::thread::spawn(move || {
5549 b.wait();
5550 event_bus() as *const CardEventBus as usize
5551 }));
5552 }
5553 let ptrs: Vec<usize> = handles.into_iter().map(|h| h.join().unwrap()).collect();
5554 let first = ptrs[0];
5555 for p in &ptrs {
5556 assert_eq!(*p, first, "singleton identity must hold across threads");
5557 }
5558 }
5559
5560 #[test]
5561 fn test_subscriber_stats_concurrent_update() {
5562 let stats = Arc::new(SubscriberStats::default());
5563 let n_threads = 4;
5564 let per_thread = 250;
5565 let barrier = Arc::new(Barrier::new(n_threads));
5566 let mut handles = Vec::new();
5567 for t in 0..n_threads {
5568 let s = stats.clone();
5569 let b = barrier.clone();
5570 handles.push(std::thread::spawn(move || {
5571 b.wait();
5572 for i in 0..per_thread {
5573 let kind = if (t + i) % 2 == 0 {
5574 CardEventKind::Created
5575 } else {
5576 CardEventKind::Appended
5577 };
5578 s.record_ok("mock://same-subscriber", kind);
5579 }
5580 }));
5581 }
5582 for h in handles {
5583 h.join().unwrap();
5584 }
5585 let snap = stats.snapshot();
5586 let row = snap
5587 .iter()
5588 .find(|r| r.sink == "mock://same-subscriber")
5589 .expect("row");
5590 let expected = (n_threads * per_thread) as u64;
5591 let ok_total: u64 = row.ok.values().sum();
5592 assert_eq!(ok_total, expected, "all increments must be counted");
5593 }
5594
5595 #[test]
5596 fn test_subscriber_stats_poison_recovery() {
5597 let stats = Arc::new(SubscriberStats::default());
5598 stats.record_ok("mock://poison", CardEventKind::Created);
5600
5601 let s_clone = stats.clone();
5603 let _ = std::thread::spawn(move || {
5604 let _g = s_clone.inner.lock().unwrap();
5605 panic!("intentional poison");
5606 })
5607 .join();
5608
5609 let snap = stats.snapshot();
5611 assert!(!snap.is_empty(), "snapshot after poison must still work");
5612 let ok1: u64 = snap[0].ok.values().sum();
5613 assert_eq!(ok1, 1);
5614
5615 stats.record_ok("mock://poison", CardEventKind::Created);
5617 let snap2 = stats.snapshot();
5618 let ok2: u64 = snap2[0].ok.values().sum();
5619 assert_eq!(ok2, 2);
5620 }
5621
5622 #[test]
5623 fn test_atomic_tmp_seq_unique_under_concurrency() {
5624 let dir = tempfile::tempdir().unwrap();
5627 let barrier = Arc::new(Barrier::new(8));
5628 let mut handles = Vec::new();
5629 for i in 0..8 {
5630 let d = dir.path().to_path_buf();
5631 let b = barrier.clone();
5632 handles.push(std::thread::spawn(move || {
5633 b.wait();
5634 let dest = d.join(format!("file_{i}.bin"));
5635 atomic_write(&dest, b"x").unwrap();
5636 dest.file_name().unwrap().to_string_lossy().to_string()
5638 }));
5639 }
5640 let names: HashSet<String> = handles.into_iter().map(|h| h.join().unwrap()).collect();
5641 assert_eq!(names.len(), 8, "all dest names must be unique");
5642 }
5645
5646 #[test]
5647 fn test_atomic_tmp_seq_wraps_without_panic() {
5648 let seq = AtomicU64::new(u64::MAX - 1);
5651 let a = seq.fetch_add(1, Ordering::Relaxed);
5652 let b = seq.fetch_add(1, Ordering::Relaxed);
5653 let c = seq.fetch_add(1, Ordering::Relaxed);
5654 assert_eq!(a, u64::MAX - 1);
5655 assert_eq!(b, u64::MAX);
5656 assert_eq!(c, 0, "u64 fetch_add must wrap to 0");
5657 }
5658
5659 #[test]
5660 fn test_rename_atomicity_same_volume() {
5661 let dir = tempfile::tempdir().unwrap();
5665 let dest = dir.path().join("shared.bin");
5666 let barrier = Arc::new(Barrier::new(2));
5667 let mut handles = Vec::new();
5668 for i in 0..2u8 {
5669 let d = dest.clone();
5670 let b = barrier.clone();
5671 handles.push(std::thread::spawn(move || {
5672 b.wait();
5673 let payload = vec![i; 64];
5674 atomic_write(&d, &payload)
5675 }));
5676 }
5677 let mut saw_ok = 0;
5678 for h in handles {
5679 #[cfg(unix)]
5680 {
5681 h.join().unwrap().unwrap();
5683 saw_ok += 1;
5684 }
5685 #[cfg(not(unix))]
5686 {
5687 if h.join().unwrap().is_ok() {
5689 saw_ok += 1;
5690 }
5691 }
5692 }
5693 assert!(saw_ok >= 1, "at least one rename must succeed");
5694 assert!(dest.exists(), "dest must exist after concurrent rename");
5695 let bytes = fs::read(&dest).unwrap();
5696 assert!(bytes == vec![0u8; 64] || bytes == vec![1u8; 64]);
5697 }
5698
5699 #[test]
5700 fn test_fanout_concurrent_create_with_store() {
5701 let primary = tempfile::tempdir().unwrap();
5702 let sub = tempfile::tempdir().unwrap();
5703 let fs_sub = Arc::new(FileCardSubscriber::new(sub.path().to_path_buf()));
5704 with_bus_subscribers(vec![fs_sub.clone()], |bus| {
5705 let primary_path = primary.path().to_path_buf();
5706 let barrier = Arc::new(Barrier::new(4));
5707 let mut handles = Vec::new();
5708 for i in 0..4 {
5709 let pp = primary_path.clone();
5710 let b = barrier.clone();
5711 handles.push(std::thread::spawn(move || {
5712 INSIDE_BUS_TEST.with(|flag| flag.set(true));
5718 b.wait();
5719 let store = FileCardStore::new(pp);
5720 create_with_store(
5721 &store,
5722 json!({
5723 "card_id": format!("concur_card_{i}"),
5724 "pkg": { "name": "concur_pkg" },
5725 }),
5726 )
5727 .unwrap()
5728 .0
5729 }));
5730 }
5731 let ids: Vec<String> = handles.into_iter().map(|h| h.join().unwrap()).collect();
5732 assert_eq!(ids.len(), 4);
5733 for id in &ids {
5734 let p = sub.path().join("concur_pkg").join(format!("{id}.toml"));
5735 assert!(p.exists(), "subscriber must have card {id}");
5736 }
5737 let snap = bus.stats().snapshot();
5738 let row = snap
5739 .iter()
5740 .find(|r| r.sink == fs_sub.describe())
5741 .expect("row");
5742 let ok_total: u64 = row.ok.values().sum();
5743 assert_eq!(
5744 ok_total, 4,
5745 "subscriber must have recorded 4 successful deliveries"
5746 );
5747 });
5748 }
5749
5750 fn backfill_primary_with_cards(
5756 pkg: &str,
5757 count: usize,
5758 ) -> (tempfile::TempDir, FileCardStore, Vec<String>) {
5759 let primary = tempfile::tempdir().unwrap();
5760 let store = FileCardStore::new(primary.path().to_path_buf());
5761 let mut ids = Vec::new();
5762 for i in 0..count {
5763 let (id, _) = create_with_store(
5764 &store,
5765 json!({
5766 "card_id": format!("{pkg}_{i}"),
5767 "pkg": { "name": pkg },
5768 }),
5769 )
5770 .unwrap();
5771 ids.push(id);
5772 }
5773 (primary, store, ids)
5774 }
5775
5776 #[test]
5777 fn backfill_pushes_missing_cards() {
5778 let sub_dir = tempfile::tempdir().unwrap();
5779 let fs_sub = Arc::new(FileCardSubscriber::new(sub_dir.path().to_path_buf()));
5780 let uri = fs_sub.describe();
5781 with_bus_subscribers(vec![fs_sub.clone()], |_bus| {
5782 let bus = event_bus();
5784 bus.replace_subscribers_for_test(Vec::new());
5785 let (_primary, store, ids) = backfill_primary_with_cards("backfill_push_pkg", 2);
5786 bus.replace_subscribers_for_test(vec![fs_sub.clone()]);
5787
5788 let report = card_sink_backfill_with_store(&store, &uri, false).unwrap();
5789 assert_eq!(report.pushed.len(), 2);
5790 assert_eq!(report.skipped.len(), 0);
5791 assert!(report.failed.is_empty());
5792 for id in &ids {
5793 let p = sub_dir
5794 .path()
5795 .join("backfill_push_pkg")
5796 .join(format!("{id}.toml"));
5797 assert!(p.exists(), "card {id} must exist on subscriber");
5798 }
5799 });
5800 }
5801
5802 #[test]
5803 fn backfill_skips_existing_on_subscriber() {
5804 let sub_dir = tempfile::tempdir().unwrap();
5805 let fs_sub = Arc::new(FileCardSubscriber::new(sub_dir.path().to_path_buf()));
5806 let uri = fs_sub.describe();
5807 with_bus_subscribers(vec![fs_sub.clone()], |_bus| {
5808 let (_primary, store, _ids) = backfill_primary_with_cards("backfill_skip_pkg", 3);
5810 let report = card_sink_backfill_with_store(&store, &uri, false).unwrap();
5811 assert_eq!(report.pushed.len(), 0);
5812 assert_eq!(report.skipped.len(), 3);
5813 assert!(report.failed.is_empty());
5814 });
5815 }
5816
5817 #[test]
5818 fn backfill_dry_run_no_writes() {
5819 let sub_dir = tempfile::tempdir().unwrap();
5820 let fs_sub = Arc::new(FileCardSubscriber::new(sub_dir.path().to_path_buf()));
5821 let uri = fs_sub.describe();
5822 with_bus_subscribers(vec![fs_sub.clone()], |_bus| {
5823 let bus = event_bus();
5824 bus.replace_subscribers_for_test(Vec::new());
5825 let (_primary, store, ids) = backfill_primary_with_cards("backfill_dry_pkg", 2);
5826 bus.replace_subscribers_for_test(vec![fs_sub.clone()]);
5827
5828 let report = card_sink_backfill_with_store(&store, &uri, true).unwrap();
5829 assert_eq!(
5830 report.pushed.len(),
5831 2,
5832 "pushed must list ids even in dry run"
5833 );
5834 for id in &ids {
5835 let p = sub_dir
5836 .path()
5837 .join("backfill_dry_pkg")
5838 .join(format!("{id}.toml"));
5839 assert!(!p.exists(), "dry run must NOT write card {id}");
5840 }
5841 let snap = bus.stats().snapshot();
5843 if let Some(row) = snap.iter().find(|r| r.sink == uri) {
5844 let total: u64 = row.ok.values().sum::<u64>() + row.err.values().sum::<u64>();
5845 assert_eq!(total, 0, "dry run must not touch stats");
5846 }
5847 });
5848 }
5849
5850 #[test]
5851 fn backfill_drifted_card_skipped_not_overwritten() {
5852 let sub_dir = tempfile::tempdir().unwrap();
5853 let fs_sub = Arc::new(FileCardSubscriber::new(sub_dir.path().to_path_buf()));
5854 let uri = fs_sub.describe();
5855 with_bus_subscribers(vec![fs_sub.clone()], |_bus| {
5856 let bus = event_bus();
5857 bus.replace_subscribers_for_test(Vec::new());
5858 let (_primary, store, ids) = backfill_primary_with_cards("backfill_drift_pkg", 1);
5859 let id = &ids[0];
5860
5861 let sub_card_dir = sub_dir.path().join("backfill_drift_pkg");
5863 fs::create_dir_all(&sub_card_dir).unwrap();
5864 let sub_card = sub_card_dir.join(format!("{id}.toml"));
5865 fs::write(&sub_card, "drifted=true\n").unwrap();
5866
5867 bus.replace_subscribers_for_test(vec![fs_sub.clone()]);
5868 let report = card_sink_backfill_with_store(&store, &uri, false).unwrap();
5869 assert_eq!(report.skipped, vec![id.clone()]);
5870 assert!(report.pushed.is_empty());
5871 let after = fs::read_to_string(&sub_card).unwrap();
5872 assert_eq!(after, "drifted=true\n", "drifted copy must be preserved");
5873 });
5874 }
5875
5876 #[test]
5877 fn backfill_includes_samples() {
5878 let sub_dir = tempfile::tempdir().unwrap();
5879 let fs_sub = Arc::new(FileCardSubscriber::new(sub_dir.path().to_path_buf()));
5880 let uri = fs_sub.describe();
5881 with_bus_subscribers(vec![fs_sub.clone()], |_bus| {
5882 let bus = event_bus();
5883 bus.replace_subscribers_for_test(Vec::new());
5884 let (_primary, store, ids) = backfill_primary_with_cards("backfill_samples_pkg", 1);
5885 let id = &ids[0];
5886 write_samples_with_store(&store, id, vec![json!({ "case": "c0" })]).unwrap();
5887 bus.replace_subscribers_for_test(vec![fs_sub.clone()]);
5888
5889 let report = card_sink_backfill_with_store(&store, &uri, false).unwrap();
5890 assert_eq!(report.pushed, vec![id.clone()]);
5891 assert_eq!(report.pushed_samples, vec![id.clone()]);
5892 let sub_samples = sub_dir
5893 .path()
5894 .join("backfill_samples_pkg")
5895 .join(format!("{id}.samples.jsonl"));
5896 assert!(sub_samples.exists());
5897 assert!(fs::read_to_string(&sub_samples).unwrap().contains("c0"));
5898 });
5899 }
5900
5901 #[test]
5902 fn backfill_unknown_sink_err() {
5903 with_bus_subscribers(Vec::new(), |_bus| {
5904 let (_primary, store, _ids) = backfill_primary_with_cards("backfill_unknown_pkg", 1);
5905 let err = card_sink_backfill_with_store(&store, "file:///nonexistent/sink", false)
5906 .unwrap_err();
5907 assert!(
5908 err.starts_with("unknown sink"),
5909 "must reject unregistered sink; got: {err}"
5910 );
5911 });
5912 }
5913
5914 #[test]
5915 fn backfill_bypasses_bus_fanout() {
5916 let sub_a_dir = tempfile::tempdir().unwrap();
5919 let sub_b_dir = tempfile::tempdir().unwrap();
5920 let fa = Arc::new(FileCardSubscriber::new(sub_a_dir.path().to_path_buf()));
5921 let fb = Arc::new(FileCardSubscriber::new(sub_b_dir.path().to_path_buf()));
5922 let uri_b = fb.describe();
5923 with_bus_subscribers(
5924 vec![
5925 fa.clone() as Arc<dyn CardSubscriber>,
5926 fb.clone() as Arc<dyn CardSubscriber>,
5927 ],
5928 |bus| {
5929 bus.replace_subscribers_for_test(vec![fa.clone()]);
5931 let (_primary, store, _ids) = backfill_primary_with_cards("backfill_bypass_pkg", 2);
5932 let before = bus
5934 .stats()
5935 .snapshot()
5936 .into_iter()
5937 .find(|r| r.sink == fa.describe())
5938 .map(|r| r.ok.get("created").copied().unwrap_or(0))
5939 .unwrap_or(0);
5940 bus.replace_subscribers_for_test(vec![fa.clone(), fb.clone()]);
5942 card_sink_backfill_with_store(&store, &uri_b, false).unwrap();
5943 let after = bus
5944 .stats()
5945 .snapshot()
5946 .into_iter()
5947 .find(|r| r.sink == fa.describe())
5948 .map(|r| r.ok.get("created").copied().unwrap_or(0))
5949 .unwrap_or(0);
5950 assert_eq!(
5951 before, after,
5952 "backfill target B must not cause fan-out to subscriber A"
5953 );
5954 },
5955 );
5956 }
5957
5958 #[test]
5959 fn backfill_updates_subscriber_stats() {
5960 let sub_dir = tempfile::tempdir().unwrap();
5961 let fs_sub = Arc::new(FileCardSubscriber::new(sub_dir.path().to_path_buf()));
5962 let uri = fs_sub.describe();
5963 with_bus_subscribers(vec![fs_sub.clone()], |bus| {
5964 bus.replace_subscribers_for_test(Vec::new());
5965 let (_primary, store, _ids) = backfill_primary_with_cards("backfill_stats_pkg", 2);
5966 bus.replace_subscribers_for_test(vec![fs_sub.clone()]);
5967
5968 card_sink_backfill_with_store(&store, &uri, false).unwrap();
5969 let snap = bus.stats().snapshot();
5970 let row = snap.iter().find(|r| r.sink == uri).expect("row");
5971 assert_eq!(
5972 row.ok.get("created").copied().unwrap_or(0),
5973 2,
5974 "backfill must increment ok[created] on the target sink"
5975 );
5976 });
5977 }
5978
5979 #[test]
5980 fn backfill_failure_records_err_stat() {
5981 struct FailingSub {
5983 uri: String,
5984 }
5985 impl CardSubscriber for FailingSub {
5986 fn on_event(&self, _ev: &CardEvent) -> Result<(), String> {
5987 Err("synthetic backfill failure".into())
5988 }
5989 fn has_card(&self, _card_id: &str) -> Result<bool, String> {
5990 Ok(false)
5991 }
5992 fn describe(&self) -> String {
5993 self.uri.clone()
5994 }
5995 }
5996 let uri = "mock://backfill-fail".to_string();
5997 let failing: Arc<dyn CardSubscriber> = Arc::new(FailingSub { uri: uri.clone() });
5998 with_bus_subscribers(vec![failing], |bus| {
5999 bus.replace_subscribers_for_test(Vec::new());
6000 let (_primary, store, _ids) = backfill_primary_with_cards("backfill_fail_pkg", 1);
6001 let reinstall: Arc<dyn CardSubscriber> = Arc::new(FailingSub { uri: uri.clone() });
6003 bus.replace_subscribers_for_test(vec![reinstall]);
6004
6005 let report = card_sink_backfill_with_store(&store, &uri, false).unwrap();
6006 assert_eq!(
6007 report.failed.len(),
6008 1,
6009 "failed must record the synthetic err"
6010 );
6011 assert!(report.pushed.is_empty());
6012 let snap = bus.stats().snapshot();
6013 let row = snap.iter().find(|r| r.sink == uri).expect("row");
6014 assert!(
6015 row.err.get("created").copied().unwrap_or(0) >= 1,
6016 "failing publish must increment err[created]"
6017 );
6018 assert!(row.last_error.is_some());
6019 });
6020 }
6021
6022 #[test]
6023 fn test_oncelock_set_after_init_returns_err() {
6024 let _ = event_bus();
6026 let result = install_event_bus_for_test(CardEventBus::new(Vec::new()));
6027 assert!(
6028 result.is_err(),
6029 "install after init must return Err per OnceLock contract"
6030 );
6031 assert_eq!(result.unwrap_err(), "bus already initialized");
6032 }
6033
6034 mod log_sink_subscriber_tests {
6037 use super::*;
6038 use std::collections::HashSet;
6039 use std::sync::atomic::AtomicBool;
6040 use std::sync::Barrier;
6041 use std::time::{Duration, Instant};
6042
6043 fn make_events() -> Vec<CardEvent> {
6046 vec![
6047 CardEvent::Created {
6048 pkg: "pkg_a".to_string(),
6049 card_id: "card_1".to_string(),
6050 toml_text: String::new(),
6051 },
6052 CardEvent::Appended {
6053 card_id: "card_1".to_string(),
6054 toml_text: String::new(),
6055 },
6056 CardEvent::SamplesWritten {
6057 card_id: "card_1".to_string(),
6058 jsonl_text: "abc\ndef\n".to_string(),
6059 },
6060 CardEvent::AliasesWritten {
6061 toml_text: "aliases".to_string(),
6062 },
6063 ]
6064 }
6065
6066 #[test]
6069 fn t1_single_sink_receives_all_variants() {
6070 let sub = LogSinkCardSubscriber::new();
6071 let sink = LogSink::new();
6072 {
6073 let mut g = sub.sinks.lock().unwrap();
6074 g.push((0, sink.clone()));
6075 }
6076 for ev in &make_events() {
6077 sub.on_event(ev).expect("on_event returns Ok");
6078 }
6079 let entries = sink.entries();
6080 assert_eq!(entries.len(), 4, "one entry per variant");
6081 for e in &entries {
6082 assert_eq!(e.source, "alc.card");
6083 assert_eq!(e.level, "info");
6084 }
6085 }
6086
6087 #[test]
6089 fn t2_multiple_sinks_fan_out() {
6090 let sub = LogSinkCardSubscriber::new();
6091 let sink_a = LogSink::new();
6092 let sink_b = LogSink::new();
6093 {
6094 let mut g = sub.sinks.lock().unwrap();
6095 g.push((0, sink_a.clone()));
6096 g.push((1, sink_b.clone()));
6097 }
6098 let ev = CardEvent::Created {
6099 pkg: "pkg_x".into(),
6100 card_id: "card_x".into(),
6101 toml_text: String::new(),
6102 };
6103 sub.on_event(&ev).expect("on_event returns Ok");
6104 assert_eq!(sink_a.entries().len(), 1);
6105 assert_eq!(sink_b.entries().len(), 1);
6106 }
6107
6108 #[test]
6110 fn t3_drop_stops_delivery() {
6111 let _gate = bus_test_gate().lock().unwrap_or_else(|p| p.into_inner());
6112 INSIDE_BUS_TEST.with(|f| f.set(true));
6113 struct Restore;
6114 impl Drop for Restore {
6115 fn drop(&mut self) {
6116 INSIDE_BUS_TEST.with(|f| f.set(false));
6117 }
6118 }
6119 let _r = Restore;
6120
6121 let sink = LogSink::new();
6122 let reg = register_log_sink(sink.clone());
6123 let ev = CardEvent::AliasesWritten {
6124 toml_text: "aliases".to_string(),
6125 };
6126 log_sink_subscriber().on_event(&ev).unwrap();
6127 let n_before_drop = sink.entries().len();
6128 assert!(n_before_drop >= 1, "sink must receive event before drop");
6129 drop(reg);
6130 log_sink_subscriber().on_event(&ev).unwrap();
6131 assert_eq!(
6132 sink.entries().len(),
6133 n_before_drop,
6134 "no new entries after drop"
6135 );
6136 }
6137
6138 #[test]
6140 fn t4_describe_returns_canonical_uri() {
6141 let sub = LogSinkCardSubscriber::new();
6142 assert_eq!(sub.describe(), "log-sink://alc.card");
6143 assert_eq!(LogSinkCardSubscriber::URI, "log-sink://alc.card");
6144 }
6145
6146 #[test]
6148 fn t5_on_event_never_returns_err() {
6149 let sub = LogSinkCardSubscriber::new();
6150 for ev in &make_events() {
6151 assert!(sub.on_event(ev).is_ok(), "on_event must always be Ok(())");
6152 }
6153 }
6154
6155 #[test]
6157 fn t6_message_format_literals() {
6158 let sub = LogSinkCardSubscriber::new();
6159 let sink = LogSink::new();
6160 {
6161 let mut g = sub.sinks.lock().unwrap();
6162 g.push((0, sink.clone()));
6163 }
6164
6165 let ev_created = CardEvent::Created {
6166 pkg: "pkg_a".into(),
6167 card_id: "card_1".into(),
6168 toml_text: String::new(),
6169 };
6170 sub.on_event(&ev_created).unwrap();
6171
6172 let ev_appended = CardEvent::Appended {
6173 card_id: "card_1".into(),
6174 toml_text: String::new(),
6175 };
6176 sub.on_event(&ev_appended).unwrap();
6177
6178 let ev_samples = CardEvent::SamplesWritten {
6179 card_id: "card_1".into(),
6180 jsonl_text: "abc\ndef\n".into(),
6181 };
6182 sub.on_event(&ev_samples).unwrap();
6183
6184 let ev_aliases = CardEvent::AliasesWritten {
6185 toml_text: "aliases".into(),
6186 };
6187 sub.on_event(&ev_aliases).unwrap();
6188
6189 let entries = sink.entries();
6190 assert_eq!(entries.len(), 4);
6191 assert_eq!(entries[0].message, "created pkg=pkg_a card_id=card_1");
6192 assert_eq!(entries[1].message, "appended card_id=card_1");
6193 assert_eq!(
6194 entries[2].message,
6195 format!(
6196 "samples_written card_id=card_1 bytes={}",
6197 "abc\ndef\n".len()
6198 )
6199 );
6200 assert_eq!(
6201 entries[3].message,
6202 format!("aliases_written bytes={}", "aliases".len())
6203 );
6204 }
6205
6206 fn with_bus_owner<F: FnOnce()>(f: F) {
6213 let _gate = bus_test_gate().lock().unwrap_or_else(|p| p.into_inner());
6214 INSIDE_BUS_TEST.with(|f| f.set(true));
6215 struct Restore;
6216 impl Drop for Restore {
6217 fn drop(&mut self) {
6218 INSIDE_BUS_TEST.with(|f| f.set(false));
6219 }
6220 }
6221 let _r = Restore;
6222 f();
6223 }
6224
6225 #[test]
6228 fn test_log_sink_subscriber_multithread_publish_fanout() {
6229 with_bus_owner(|| {
6230 let bus = event_bus();
6231 bus.replace_subscribers_for_test(vec![
6232 log_sink_subscriber() as Arc<dyn CardSubscriber>
6233 ]);
6234 bus.reset_stats_for_test();
6235
6236 let sink = LogSink::new();
6237 let _reg = register_log_sink(sink.clone());
6238
6239 let n_threads = 8usize;
6240 let per_thread = 100usize;
6241 let barrier = Arc::new(Barrier::new(n_threads));
6242 let handles: Vec<_> = (0..n_threads)
6243 .map(|_| {
6244 let b = Arc::clone(&barrier);
6245 std::thread::spawn(move || {
6246 INSIDE_BUS_TEST.with(|f| f.set(true));
6247 b.wait();
6248 for _ in 0..per_thread {
6249 event_bus().publish(&CardEvent::Appended {
6250 card_id: "card_x".into(),
6251 toml_text: String::new(),
6252 });
6253 }
6254 })
6255 })
6256 .collect();
6257 for h in handles {
6258 h.join().expect("thread join");
6259 }
6260
6261 let snap = bus.stats().snapshot();
6264 let row = snap
6265 .iter()
6266 .find(|r| r.sink == LogSinkCardSubscriber::URI)
6267 .expect("log-sink row");
6268 assert_eq!(
6269 row.ok.get("appended").copied().unwrap_or(0),
6270 (n_threads * per_thread) as u64
6271 );
6272
6273 bus.replace_subscribers_for_test(Vec::new());
6275 bus.reset_stats_for_test();
6276 });
6277 }
6278
6279 #[test]
6283 fn test_log_sink_registration_drop_during_publish() {
6284 with_bus_owner(|| {
6285 let bus = event_bus();
6286 bus.replace_subscribers_for_test(vec![
6287 log_sink_subscriber() as Arc<dyn CardSubscriber>
6288 ]);
6289 bus.reset_stats_for_test();
6290
6291 let sink = LogSink::new();
6292 let reg = register_log_sink(sink.clone());
6293
6294 let stop = Arc::new(AtomicBool::new(false));
6295 let stop_clone = Arc::clone(&stop);
6296 let publisher = std::thread::spawn(move || {
6297 INSIDE_BUS_TEST.with(|f| f.set(true));
6298 while !stop_clone.load(Ordering::Relaxed) {
6299 event_bus().publish(&CardEvent::AliasesWritten {
6300 toml_text: "a".into(),
6301 });
6302 }
6303 });
6304
6305 std::thread::sleep(Duration::from_millis(20));
6307 let drop_start = Instant::now();
6308 drop(reg);
6309 let drop_elapsed = drop_start.elapsed();
6310 assert!(
6311 drop_elapsed < Duration::from_millis(200),
6312 "drop must not deadlock: took {drop_elapsed:?}"
6313 );
6314
6315 std::thread::sleep(Duration::from_millis(20));
6318 let snap1 = sink.entries().len();
6319 std::thread::sleep(Duration::from_millis(30));
6320 let snap2 = sink.entries().len();
6321 assert_eq!(
6322 snap1, snap2,
6323 "no new entries after drop (before/after equal, both = {snap1})"
6324 );
6325
6326 stop.store(true, Ordering::Relaxed);
6327 publisher.join().expect("publisher join");
6328
6329 bus.replace_subscribers_for_test(Vec::new());
6330 bus.reset_stats_for_test();
6331 });
6332 }
6333
6334 #[test]
6337 fn test_log_sink_subscriber_oncelock_init_race() {
6338 with_bus_owner(|| {
6339 let bus = event_bus();
6340 let _ = log_sink_subscriber();
6344 bus.replace_subscribers_for_test(vec![
6345 log_sink_subscriber() as Arc<dyn CardSubscriber>
6346 ]);
6347
6348 let n = 4usize;
6349 let barrier = Arc::new(Barrier::new(n));
6350 let handles: Vec<_> = (0..n)
6351 .map(|_| {
6352 let b = Arc::clone(&barrier);
6353 std::thread::spawn(move || {
6354 b.wait();
6355 log_sink_subscriber()
6356 })
6357 })
6358 .collect();
6359 let arcs: Vec<Arc<LogSinkCardSubscriber>> =
6360 handles.into_iter().map(|h| h.join().unwrap()).collect();
6361 for a in arcs.windows(2) {
6362 assert!(
6363 Arc::ptr_eq(&a[0], &a[1]),
6364 "all threads must observe the same singleton Arc"
6365 );
6366 }
6367
6368 let count = bus
6370 .subscriber_uris()
6371 .iter()
6372 .filter(|u| *u == LogSinkCardSubscriber::URI)
6373 .count();
6374 assert_eq!(count, 1, "bus must have exactly 1 log-sink subscriber");
6375
6376 bus.replace_subscribers_for_test(Vec::new());
6377 });
6378 }
6379
6380 #[test]
6383 fn test_log_sink_subscriber_fanout_during_registration() {
6384 with_bus_owner(|| {
6385 let bus = event_bus();
6386 bus.replace_subscribers_for_test(vec![
6387 log_sink_subscriber() as Arc<dyn CardSubscriber>
6388 ]);
6389 bus.reset_stats_for_test();
6390
6391 let stop = Arc::new(AtomicBool::new(false));
6392 let stop_pub = Arc::clone(&stop);
6393 let publisher = std::thread::spawn(move || {
6394 INSIDE_BUS_TEST.with(|f| f.set(true));
6395 while !stop_pub.load(Ordering::Relaxed) {
6396 event_bus().publish(&CardEvent::AliasesWritten {
6397 toml_text: "a".into(),
6398 });
6399 }
6400 });
6401
6402 let stop_reg = Arc::clone(&stop);
6403 let registrar = std::thread::spawn(move || {
6404 while !stop_reg.load(Ordering::Relaxed) {
6405 let sink = LogSink::new();
6406 let _reg = register_log_sink(sink);
6407 std::thread::sleep(Duration::from_micros(50));
6408 }
6409 });
6410
6411 let stop_dropper = Arc::clone(&stop);
6412 let dropper = std::thread::spawn(move || {
6413 while !stop_dropper.load(Ordering::Relaxed) {
6414 let sink = LogSink::new();
6415 let reg = register_log_sink(sink);
6416 std::thread::sleep(Duration::from_micros(25));
6417 drop(reg);
6418 }
6419 });
6420
6421 std::thread::sleep(Duration::from_millis(100));
6422 stop.store(true, Ordering::Relaxed);
6423
6424 publisher.join().expect("publisher join");
6425 registrar.join().expect("registrar join");
6426 dropper.join().expect("dropper join");
6427
6428 bus.replace_subscribers_for_test(Vec::new());
6429 bus.reset_stats_for_test();
6430 });
6431 }
6432
6433 #[test]
6435 fn test_log_sink_subscriber_atomicu64_id_uniqueness() {
6436 let n = 16usize;
6437 let barrier = Arc::new(Barrier::new(n));
6438 let handles: Vec<_> = (0..n)
6439 .map(|_| {
6440 let b = Arc::clone(&barrier);
6441 std::thread::spawn(move || {
6442 let sink = LogSink::new();
6443 b.wait();
6444 register_log_sink(sink)
6445 })
6446 })
6447 .collect();
6448 let regs: Vec<LogSinkRegistration> =
6449 handles.into_iter().map(|h| h.join().unwrap()).collect();
6450 let ids: HashSet<u64> = regs.iter().map(|r| r.id()).collect();
6451 assert_eq!(ids.len(), n, "all {n} ids must be unique");
6452 }
6453
6454 #[test]
6457 fn test_log_sink_registration_drop_poisoned_mutex() {
6458 let sub = Arc::new(LogSinkCardSubscriber::new());
6461 let sink = LogSink::new();
6462 let id = sub.next_id.fetch_add(1, Ordering::Relaxed);
6463 {
6464 let mut g = sub.sinks.lock().unwrap();
6465 g.push((id, sink.clone()));
6466 }
6467 let reg = LogSinkRegistration {
6468 subscriber: Arc::clone(&sub),
6469 id,
6470 };
6471
6472 let sub_poison = Arc::clone(&sub);
6474 let handle = std::thread::spawn(move || {
6475 let _guard = sub_poison.sinks.lock().unwrap();
6476 panic!("intentional panic to poison mutex");
6477 });
6478 let result = handle.join();
6479 assert!(result.is_err(), "poisoning thread must have panicked");
6480 assert!(
6481 sub.sinks.is_poisoned(),
6482 "mutex must be poisoned after panic"
6483 );
6484
6485 drop(reg);
6488
6489 let guard = sub.sinks.lock().unwrap_or_else(|p| p.into_inner());
6490 assert!(
6491 !guard.iter().any(|(entry_id, _)| *entry_id == id),
6492 "entry with matching id must be removed from sinks vec"
6493 );
6494 }
6495 }
6496
6497 mod store_fail_tracing_tests {
6500 use super::*;
6501 use std::sync::atomic::{AtomicBool, Ordering};
6502 use std::sync::{Arc, Mutex};
6503 use tracing::{
6504 field::{Field, Visit},
6505 span, Event, Level, Metadata, Subscriber,
6506 };
6507
6508 #[derive(Default)]
6514 struct AlcCardErrorCapture {
6515 fired: AtomicBool,
6516 messages: Mutex<Vec<String>>,
6517 }
6518
6519 impl Subscriber for AlcCardErrorCapture {
6520 fn enabled(&self, metadata: &Metadata<'_>) -> bool {
6521 metadata.level() == &Level::ERROR && metadata.target() == "alc.card"
6522 }
6523 fn new_span(&self, _: &span::Attributes<'_>) -> span::Id {
6524 span::Id::from_u64(1)
6525 }
6526 fn record(&self, _: &span::Id, _: &span::Record<'_>) {}
6527 fn record_follows_from(&self, _: &span::Id, _: &span::Id) {}
6528 fn event(&self, event: &Event<'_>) {
6529 struct MsgVisitor(Option<String>);
6530 impl Visit for MsgVisitor {
6531 fn record_debug(&mut self, field: &Field, value: &dyn std::fmt::Debug) {
6532 if field.name() == "message" {
6533 self.0 = Some(format!("{value:?}"));
6534 }
6535 }
6536 }
6537 let mut v = MsgVisitor(None);
6538 event.record(&mut v);
6539 self.fired.store(true, Ordering::SeqCst);
6540 if let Some(msg) = v.0 {
6541 self.messages.lock().unwrap().push(msg);
6542 }
6543 }
6544 fn enter(&self, _: &span::Id) {}
6545 fn exit(&self, _: &span::Id) {}
6546 }
6547
6548 struct FailingWriteStore {
6551 fail_msg: String,
6552 }
6553
6554 impl FailingWriteStore {
6555 fn new(fail_msg: &str) -> Self {
6556 Self {
6557 fail_msg: fail_msg.to_string(),
6558 }
6559 }
6560 }
6561
6562 impl CardStore for FailingWriteStore {
6563 fn write_new_card(
6564 &self,
6565 _pkg: &str,
6566 _card_id: &str,
6567 _toml_text: &str,
6568 ) -> Result<PathBuf, String> {
6569 Err(self.fail_msg.clone())
6570 }
6571 fn overwrite_card(&self, _card_id: &str, _toml_text: &str) -> Result<PathBuf, String> {
6572 Err(self.fail_msg.clone())
6573 }
6574 fn find_card_locator(&self, card_id: &str) -> Result<Option<PathBuf>, String> {
6575 Ok(Some(PathBuf::from(format!("/nonexistent/{card_id}.toml"))))
6578 }
6579 fn read_card_text(&self, _card_id: &str) -> Result<Option<String>, String> {
6580 Ok(None)
6581 }
6582 fn list_card_locators(
6583 &self,
6584 _pkg_filter: Option<&str>,
6585 ) -> Result<Vec<(String, PathBuf)>, String> {
6586 Ok(Vec::new())
6587 }
6588 fn read_locator_text(&self, _locator: &Path) -> Result<Option<String>, String> {
6589 Ok(None)
6590 }
6591 fn read_aliases(&self) -> Result<Vec<Alias>, String> {
6592 Ok(Vec::new())
6593 }
6594 fn write_aliases(&self, _aliases: &[Alias]) -> Result<(), String> {
6595 Err(self.fail_msg.clone())
6596 }
6597 fn samples_exists(&self, _card_id: &str) -> Result<bool, String> {
6598 Ok(false)
6600 }
6601 fn write_samples_text(
6602 &self,
6603 _card_id: &str,
6604 _jsonl_text: &str,
6605 ) -> Result<PathBuf, String> {
6606 Err(self.fail_msg.clone())
6607 }
6608 fn read_samples_text(&self, _card_id: &str) -> Result<Option<String>, String> {
6609 Ok(None)
6610 }
6611 fn import_from_dir(
6612 &self,
6613 _source_dir: &Path,
6614 _pkg: &str,
6615 ) -> Result<(Vec<String>, Vec<String>), String> {
6616 Ok((Vec::new(), Vec::new()))
6617 }
6618 }
6619
6620 #[test]
6623 fn write_new_card_err_emits_tracing_error() {
6624 let capture = Arc::new(AlcCardErrorCapture::default());
6625 let store = FailingWriteStore::new("disk full: no space left on device");
6626 let input = json!({
6627 "pkg": { "name": "tracing_test_pkg" },
6628 "model": { "id": "test-model" },
6629 });
6630
6631 let subscriber = capture.clone();
6632 let err = tracing::subscriber::with_default(subscriber, || {
6633 create_with_store(&store, input).expect_err("write must fail")
6634 });
6635
6636 assert!(
6637 err.contains("disk full"),
6638 "Err content must propagate unchanged: got {err}"
6639 );
6640 assert!(
6641 capture.fired.load(Ordering::SeqCst),
6642 "tracing::error must have fired at target alc.card"
6643 );
6644 let msgs = capture.messages.lock().unwrap();
6645 assert!(
6646 msgs.iter().any(|m| m.contains("write_new_card failed")),
6647 "captured messages must include 'write_new_card failed', got: {msgs:?}"
6648 );
6649 }
6650
6651 #[test]
6654 fn write_samples_text_err_emits_tracing_error() {
6655 let capture = Arc::new(AlcCardErrorCapture::default());
6656 let store = FailingWriteStore::new("permission denied");
6657 let samples = vec![json!({"case_id": "c1", "score": 0.5})];
6658
6659 let subscriber = capture.clone();
6660 let err = tracing::subscriber::with_default(subscriber, || {
6661 write_samples_with_store(&store, "card_abc", samples).expect_err("write must fail")
6662 });
6663
6664 assert!(
6665 err.contains("permission denied"),
6666 "Err content must propagate unchanged: got {err}"
6667 );
6668 assert!(
6669 capture.fired.load(Ordering::SeqCst),
6670 "tracing::error must have fired at target alc.card"
6671 );
6672 let msgs = capture.messages.lock().unwrap();
6673 assert!(
6674 msgs.iter().any(|m| m.contains("write_samples_text failed")),
6675 "captured messages must include 'write_samples_text failed', got: {msgs:?}"
6676 );
6677 }
6678
6679 #[test]
6682 fn write_aliases_err_emits_tracing_error() {
6683 let capture = Arc::new(AlcCardErrorCapture::default());
6684 let store = FailingWriteStore::new("read-only filesystem");
6685
6686 let subscriber = capture.clone();
6687 let err = tracing::subscriber::with_default(subscriber, || {
6688 alias_set_with_store(&store, "best", "card_xyz", None, None)
6689 .expect_err("write must fail")
6690 });
6691
6692 assert!(
6693 err.contains("read-only filesystem"),
6694 "Err content must propagate unchanged: got {err}"
6695 );
6696 assert!(
6697 capture.fired.load(Ordering::SeqCst),
6698 "tracing::error must have fired at target alc.card"
6699 );
6700 let msgs = capture.messages.lock().unwrap();
6701 assert!(
6702 msgs.iter().any(|m| m.contains("write_aliases failed")),
6703 "captured messages must include 'write_aliases failed', got: {msgs:?}"
6704 );
6705 }
6706 }
6707}