Skip to main content

algocline_engine/
card.rs

1//! Card storage — immutable run-result snapshots.
2//!
3//! A Card is a frozen record of a strategy run: identity, parameters,
4//! model, scenario, aggregate stats, and (optionally) per-case detail.
5//! Cards are **immutable** — once written they are never modified, only
6//! annotated via additive `append`.  Mutable **aliases** point to a
7//! Card and can be rebound freely.
8//!
9//! ## Design principles
10//!
11//! 1. **Minimal REQUIRED, maximal OPTIONAL** — v0 needs only 4 fields;
12//!    lightweight "ran this pkg" records and heavy optimize snapshots
13//!    share the same schema.
14//! 2. **Immutable append-only** — no overwrite, no delete.  New data is
15//!    added via `append` (new top-level keys only) or by creating a new
16//!    Card with a fresh `card_id`.
17//! 3. **Two-tier storage** — TOML for human-readable aggregate, JSONL
18//!    sidecar for machine-parseable per-case detail.
19//! 4. **File-primary** — files are the source of truth; in-memory state
20//!    is cache.  Cards can be copied, diffed, and version-controlled.
21//!
22//! ## Storage layout (two-tier)
23//!
24//! | Tier | File | Content |
25//! |------|------|---------|
26//! | **Tier 1** | `~/.algocline/cards/{pkg}/{card_id}.toml` | Aggregate scalars, decisions, identity, params |
27//! | **Tier 2** | `~/.algocline/cards/{pkg}/{card_id}.samples.jsonl` | Per-case raw data (JSONL, write-once) |
28//!
29//! Tier 1 holds a shareable summary (a few KB). Tier 2 holds per-case
30//! detail ��� the engine does not interpret its columns; packages define
31//! their own schema.
32//!
33//! Alias table: `~/.algocline/cards/_aliases.toml` (global).
34//!
35//! ## card_id naming
36//!
37//! `{pkg}_{model_short}_{compact_ts}_{hash6}`
38//!
39//! - `compact_ts`: `YYYYMMDDTHHMMSS` in UTC
40//! - `hash6`: first 6 hex chars of DJB2 param fingerprint
41//! - Example: `cot_opus46_20260412T061500_a3f9c1`
42//!
43//! ## v0 schema (frozen)
44//!
45//! ### REQUIRED (minimum valid Card)
46//!
47//! | Field | Type | Example |
48//! |-------|------|---------|
49//! | `schema_version` | string | `"card/v0"` |
50//! | `card_id` | string | `"cot_opus46_20260412T061500_a3f9c1"` |
51//! | `created_at` | string (RFC 3339) | `"2026-04-12T06:15:00Z"` |
52//! | `[pkg].name` | string | `"cot"` |
53//!
54//! ### OPTIONAL (auto-injected where possible)
55//!
56//! | Section | Fields |
57//! |---------|--------|
58//! | `[pkg]` | `version`, `category`, `source`, `source_ref`, `source_sha` |
59//! | `[runtime]` | `alc_version`, `lua_version`, `host_os`, `git_sha` |
60//! | `[model]` | `provider`, `id`, `id_short`, `cutoff` |
61//! | `[params]` | Free-form ctx snapshot; `param_fingerprint` for DJB2 hash |
62//! | `[strategy_params]` | Strategy-tunable parameters surfaced for sweeps / optimizers (e.g. `alpha`, `temperature`, `depth`). Free-form, but `where`-queryable as a first-class section |
63//! | `[scenario]` | `name`, `source`, `case_count`, `grader` |
64//! | `[stats]` | `pass_rate`, `mean_score`, `std`, `median`, `min`, `max`, `n` |
65//! | `[stats.by_bucket]` | Disaggregated sub-bucket stats (array of tables) |
66//! | `[cost]` | `llm_calls`, `input_tokens`, `output_tokens`, `elapsed_ms`, `usd_estimate` |
67//! | `[optimize]` | `target`, `search`, `rounds_used`, `top_k` (for optimize Cards) |
68//! | `[metadata]` | Free-form escape hatch. Recognized lineage conventions: `prior_card_id` (parent Card id), `prior_relation` (relation kind, e.g. `"sweep_variant"`, `"reflection_of"`, `"derived_from"`) |
69//!
70//! ## Lua API (`alc.card.*`)
71//!
72//! | Function | Description |
73//! |----------|-------------|
74//! | `create(table)` | Write new Card (Tier 1). Returns `{ card_id, path }` |
75//! | `get(card_id)` | Read Card by id. Returns table or nil |
76//! | `list(filter?)` | List Cards as summaries (newest first) |
77//! | `find(query?)` | Prisma-style `where` DSL + dotted-path `order_by` + `offset`/`limit` |
78//! | `append(card_id, fields)` | Additive-only annotation (new keys only) |
79//! | `alias_set(name, card_id, opts?)` | Pin mutable alias |
80//! | `alias_list(filter?)` | List aliases |
81//! | `get_by_alias(name)` | Resolve alias → full Card |
82//! | `write_samples(card_id, samples)` | Write Tier 2 sidecar (write-once) |
83//! | `read_samples(card_id, opts?)` | Read Tier 2 with `where` filtering + offset/limit paging |
84//! | `lineage(query)` | Walk ancestry/descendants via `metadata.prior_card_id` |
85
86use 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
99// ═══════════════════════════════════════════════════════════════
100// CardStore trait — physical I/O abstraction.
101// ═══════════════════════════════════════════════════════════════
102//
103// Card domain logic (schema / Query DSL / Lineage) is backend-
104// neutral. Only the physical read/write layer is swappable.
105//
106// The default backend is `FileCardStore`, which preserves the
107// legacy `~/.algocline/cards/{pkg}/{card_id}.toml` layout
108// byte-for-byte. Alternative backends (PathCardStore, SqliteCardStore,
109// MemoryCardStore) can be added by implementing this trait.
110//
111// Locators are `PathBuf` values. For FileCardStore they are real
112// filesystem paths; for non-file backends they are synthetic paths
113// (e.g. `sqlite:///db.sqlite#card/{id}`) — the value is opaque to
114// the domain layer and only exposed via the Lua `alc.card.create`
115// / `alc.card.write_samples` return values.
116
117/// Storage backend for Cards.
118///
119/// Implementations must be `Send + Sync` so that they can be shared
120/// across Lua host threads safely. All methods may fail with an
121/// error `String` describing the backend-specific failure.
122pub trait CardStore: Send + Sync {
123    // ─── Card CRUD ─────────────────────────────────────────────
124
125    /// Write a new Card (Tier 1 TOML).
126    ///
127    /// The caller has already:
128    ///   - validated `pkg` and `card_id` via [`validate_name`]
129    ///   - serialized `toml_text` with `toml::to_string_pretty`
130    ///
131    /// Fails if a Card with the same id already exists
132    /// (immutability).  Returns the locator of the written Card.
133    fn write_new_card(&self, pkg: &str, card_id: &str, toml_text: &str) -> Result<PathBuf, String>;
134
135    /// Overwrite an existing Card (append flow).
136    ///
137    /// Append is additive-only w.r.t. keys, but the underlying
138    /// TOML file is rewritten in place; callers must have validated
139    /// the additive-only constraint before calling this.
140    fn overwrite_card(&self, card_id: &str, toml_text: &str) -> Result<PathBuf, String>;
141
142    /// Locate a Card file by id. Returns `None` if not found.
143    fn find_card_locator(&self, card_id: &str) -> Result<Option<PathBuf>, String>;
144
145    /// Read a Card's raw TOML text by id. Returns `None` if missing.
146    fn read_card_text(&self, card_id: &str) -> Result<Option<String>, String>;
147
148    /// List `(pkg, locator)` pairs for every Card file in the store.
149    ///
150    /// When `pkg_filter` is `Some(name)`, restrict to that pkg
151    /// subdir. Non-existent pkg subdir yields an empty Vec.
152    ///
153    /// Order is implementation-defined — callers sort explicitly.
154    fn list_card_locators(
155        &self,
156        pkg_filter: Option<&str>,
157    ) -> Result<Vec<(String, PathBuf)>, String>;
158
159    /// Read raw TOML text from a locator returned by
160    /// [`Self::list_card_locators`]. `Ok(None)` on read failure so
161    /// scans can skip corrupt files without aborting.
162    fn read_locator_text(&self, locator: &Path) -> Result<Option<String>, String>;
163
164    // ─── Alias table ───────────────────────────────────────────
165
166    fn read_aliases(&self) -> Result<Vec<Alias>, String>;
167    fn write_aliases(&self, aliases: &[Alias]) -> Result<(), String>;
168
169    // ─── Samples sidecar ───────────────────────────────────────
170
171    /// Check whether a samples sidecar exists for `card_id`.
172    fn samples_exists(&self, card_id: &str) -> Result<bool, String>;
173
174    /// Write a samples sidecar (write-once).
175    ///
176    /// `jsonl_text` is the complete JSONL payload (one JSON line
177    /// per sample, `\n`-terminated). Fails if a sidecar already
178    /// exists. Returns the locator.
179    fn write_samples_text(&self, card_id: &str, jsonl_text: &str) -> Result<PathBuf, String>;
180
181    /// Read a samples sidecar as raw JSONL text. Returns `None`
182    /// when no sidecar exists (samples are optional).
183    fn read_samples_text(&self, card_id: &str) -> Result<Option<String>, String>;
184
185    // ─── Import ────────────────────────────────────────────────
186
187    /// Import Card files from `source_dir` into the store under
188    /// `pkg`. First-writer wins (existing Cards are skipped).
189    /// Returns `(imported, skipped)` card_id lists.
190    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
209/// DJB2 hash, hex-encoded. Used for param_fingerprint and card_id hash segment.
210fn 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
218/// Short-hash: last 6 hex chars of djb2.
219///
220/// DJB2's high bits are dominated by the `5381 * 33^n` term (same for any
221/// input of equal length), so the top hex digits collide easily for same-
222/// length inputs that differ only in a few byte positions. The low bits,
223/// driven by the most-recent bytes, mix well enough for short-hash use.
224fn 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
230/// Stable serialization of a JSON value for hashing (sorted keys).
231fn 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
274/// Derive a short model id (e.g. "claude-opus-4-6" -> "opus46").
275/// v0: best-effort. Falls back to "model" if input is empty.
276fn short_model(id: &str) -> String {
277    if id.is_empty() {
278        return "model".into();
279    }
280    // Strip common vendor prefixes.
281    let stripped = id
282        .strip_prefix("claude-")
283        .or_else(|| id.strip_prefix("gpt-"))
284        .unwrap_or(id);
285    // Keep alnum only.
286    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
297/// RFC3339 UTC "YYYY-MM-DDTHH:MM:SSZ" from current system time.
298fn 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
312/// YYYYMMDDTHHMMSS for current UTC time (compact, sortable).
313///
314/// Used in card_id generation so that:
315///   - rapid successive runs don't collide on the hash6 segment
316///   - string sort of card_id = chronological order
317fn 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
331/// Howard Hinnant's civil_from_days algorithm.
332fn 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
346/// Converter: serde_json::Value -> toml::Value.
347/// Nulls are dropped (TOML has no null). Mixed-type arrays are allowed in TOML 1.0+.
348fn 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
384/// Converter: toml::Value -> serde_json::Value (for alc.card.get()).
385fn 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
403/// Extract [pkg].name from an input JSON object. REQUIRED.
404fn 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// ─── [run] section (Phase 1-B) ─────────────────────────────────────────────
416//
417// Optional strategy run outcome recorded on a Card as `[run].status`,
418// `[run].reason`, `[run].action`.  Serialized as a nested TOML table when
419// present.  Gated at the Lua bridge layer by `[setting.card].run` — when
420// the setting is disabled, `alc.card.create` / `alc.card.append` calls
421// carrying a `run` field become no-op and return Lua nil without touching
422// the store or publishing a `CardEvent`.
423
424/// Status of a strategy run, recorded in the `[run]` section of a Card.
425///
426/// The three variants are intentionally exhaustive so that downstream
427/// analysers can pattern-match without a catch-all arm.  Serde uses
428/// snake_case so that TOML values round-trip through the Lua bridge
429/// (`succeeded` / `failed` / `skipped`).
430#[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/// Optional `[run]` section carrying strategy execution outcome.
439///
440/// * `status` is REQUIRED when the section is present.
441/// * `reason` / `action` are OPTIONAL free-form strings; when absent they
442///   are omitted from the serialized TOML entirely (`skip_serializing_if`).
443///
444/// Used only for input validation at the Lua bridge boundary — the actual
445/// Card TOML is written via the raw `serde_json::Value` path so that other
446/// top-level fields are preserved verbatim.
447#[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    /// Extract and validate the top-level `run` field from a Card input JSON.
458    ///
459    /// Returns:
460    /// * `Ok(None)` when `run` is absent (the Card carries no `[run]` section);
461    /// * `Ok(Some(section))` when `run` is a valid `[run]` table;
462    /// * `Err(msg)` when `run` is present but malformed — the error message
463    ///   always lists the accepted status tokens (`succeeded, failed, skipped`)
464    ///   so Lua callers get an actionable diagnostic.
465    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
480/// Create a new Card backed by `store`.
481pub 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    // ─── Auto-inject REQUIRED fields ──────────────────────────
492    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    // ─── param_fingerprint (if [params] present) ──────────────
500    if let Some(params) = obj.get("params").cloned() {
501        if params.is_object() {
502            let fp = djb2_hex(&stable_json(&params));
503            obj.insert("param_fingerprint".to_string(), json!(fp));
504        }
505    }
506
507    // ─── card_id generation (if absent) ───────────────────────
508    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
551/// Read a Card from `store` by id. Returns None if not found.
552// TODO(issue:e60cd19d): tracing::error emit — Card 全域 Error Log pattern スイープで対応
553pub 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/// Summary row for `alc.card.list()`.
564#[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
631/// List cards from `store`. `pkg_filter = Some("name")` restricts to that pkg subdir.
632pub 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    // Sort newest first. card_id embeds a compact UTC timestamp so it's
645    // naturally chronological; we still prefer created_at when present
646    // (some callers may override it), falling back to card_id.
647    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
659// ───────────────────────────────────────────────────────────────
660// P1 API: append / alias_{set,list} / find
661// ───────────────────────────────────────────────────────────────
662
663/// Append new top-level fields to an existing Card.
664///
665/// Semantics: **additive only**. If any top-level key in `fields` already
666/// exists in the Card, the call fails — Cards are immutable w.r.t. existing
667/// data. New top-level keys are inserted and the Card file is rewritten
668/// atomically.
669///
670/// Returns the merged Card JSON.
671pub 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
740/// Bind (or rebind) an alias to a Card in `store`.
741///
742/// Validates that `card_id` exists. If an alias with the same `name` already
743/// exists it is overwritten — the alias table is intentionally mutable even
744/// though the Cards themselves are not.
745pub 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    // Mirror the full alias table to subscribers as TOML text. The
778    // primary FileCardStore already serialized it internally; we
779    // re-serialize here using the same shape so subscribers stay in
780    // byte-for-byte parity. A serialization failure is best-effort
781    // (log + skip).
782    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
790/// Serialize `aliases` to a TOML document matching the primary
791/// `_aliases.toml` layout. Broken out so that subscribers can receive
792/// the exact byte-for-byte dump.
793fn 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
814/// Resolve an alias name to its bound Card and return the full Card JSON.
815///
816/// Shortcut for `alias_list → filter → get`. Returns `None` when the alias
817/// does not exist. Errors when the alias points at a missing Card — that
818/// would indicate a corrupt alias table (the target was deleted out of band).
819// TODO(issue:e60cd19d): tracing::error emit — Card 全域 Error Log pattern スイープで対応
820pub 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
835/// List aliases from `store`, optionally filtered by pkg.
836pub 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// ═══════════════════════════════════════════════════════════════
852// Where DSL — Prisma/Mongo-style nested predicates
853// ═══════════════════════════════════════════════════════════════
854//
855// Syntax (JSON form, as received from Lua / MCP):
856//
857//   where = {
858//     pkg: "cot",                                      // implicit eq
859//     stats: { pass_rate: { gte: 0.8 }, n: { gte: 30 } },
860//     strategy_params: { temperature: { gte: 0.7 } },
861//     prior_card_id: { exists: true },
862//     _or: [ {...}, {...} ],                           // logical ops
863//     _not: { model: { id: "claude-haiku-4-5-20251001" } },
864//   }
865//
866// Semantics:
867//   * Multiple keys in the same object → implicit AND.
868//   * Nested object value → section (path extension).
869//   * Object whose every key is a reserved operator name → leaf operator
870//     object; applies the operators to the value at the current path.
871//   * Scalar/array value → implicit eq.
872//   * Reserved logical keys: `_and` / `_or` / `_not`.
873//   * Reserved operator keys: `eq ne lt lte gt gte in nin exists
874//     contains starts_with`.  Card schemas must not use these names as
875//     field names under any section.
876//
877// Missing-field comparison:
878//   * `eq/lt/lte/gt/gte/in/contains/starts_with` → false on missing
879//   * `ne/nin`                                   → true  on missing
880//   * `exists`                                   → explicit
881//
882
883/// Single comparison operator.
884#[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/// One parsed comparison: `path` points at a nested field,
919/// `op` + `value` describe how to compare it.
920#[derive(Debug, Clone)]
921pub struct Comparison {
922    pub path: Vec<String>,
923    pub op: CmpOp,
924    pub value: Json,
925}
926
927/// Parsed predicate tree.
928#[derive(Debug, Clone)]
929pub enum Predicate {
930    And(Vec<Predicate>),
931    Or(Vec<Predicate>),
932    Not(Box<Predicate>),
933    Cmp(Comparison),
934}
935
936/// Is `obj` entirely composed of reserved operator keys?
937/// Empty objects return false (meaningless as an operator object).
938fn 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
945/// Parse a `where` JSON value into a `Predicate`.
946///
947/// `prefix` is the current nested-key path as we descend through
948/// section objects.
949pub 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                // Field key — extend the current path.
987                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                        // Leaf: operator object at this path.
993                        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                        // Nested section: recurse with extended path.
1004                        clauses.push(parse_predicate(val, &new_path)?);
1005                    }
1006                    _ => {
1007                        // Scalar/array: implicit eq.
1008                        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
1026/// Fetch a nested value from a Card JSON by dotted path.
1027fn 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
1036/// Compare two JSON scalars with a numeric/string/bool comparator.
1037/// Returns None when the types aren't comparable.
1038fn 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
1121/// Evaluate a predicate tree against a full Card JSON.
1122pub 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// ───────────────────────────────────────────────────────────────
1132// Order-by
1133// ───────────────────────────────────────────────────────────────
1134
1135/// Parsed sort key: path with optional descending flag.
1136#[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
1160/// Parse an order_by JSON value.  Accepts:
1161///   - a string: `"stats.pass_rate"` or `"-stats.pass_rate"`
1162///   - an array of strings: `["-stats.pass_rate", "created_at"]`
1163pub 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/// Query parameters for `find`.
1181#[derive(Debug, Default, Clone)]
1182pub struct FindQuery {
1183    /// Restrict scan to a single pkg subdir (I/O optimization).
1184    pub pkg: Option<String>,
1185    /// Prisma-style predicate tree.
1186    pub where_: Option<Predicate>,
1187    /// Sort keys (dotted paths, optional `-` prefix for desc).
1188    pub order_by: Vec<OrderKey>,
1189    pub limit: Option<usize>,
1190    pub offset: Option<usize>,
1191}
1192
1193/// A loaded Card row flowing through the find() pipeline.
1194///
1195/// `full` is the whole Card JSON (used by `where` evaluation and
1196/// `order_by` dotted-path lookup); `summary` is the projection
1197/// returned to callers.
1198#[derive(Debug, Clone)]
1199struct CardRow {
1200    full: Json,
1201    summary: Summary,
1202}
1203
1204/// Load a single Card file into a `CardRow`.
1205// TODO(issue:e60cd19d): tracing::error emit — Card 全域 Error Log pattern スイープで対応
1206fn 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
1248/// Compare two Card rows according to an ordered list of sort keys.
1249fn 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, // missing sorts last
1257            (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
1268/// Summary-only fields that can be sorted without loading full TOML.
1269const SUMMARY_SORT_FIELDS: &[&str] = &[
1270    "card_id",
1271    "created_at",
1272    "stats.pass_rate",
1273    "scenario.name",
1274    "model.id",
1275];
1276
1277/// Return true when the query can be answered with lightweight Summary
1278/// rows (no full-TOML load needed).
1279fn 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
1286/// Sort Summary rows using order_by keys that map to Summary fields.
1287fn 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
1312/// Filter/sort Cards across the store using the `where` DSL.
1313///
1314/// When no `where` clause is specified and `order_by` only references
1315/// summary-level fields, uses the lightweight `list_with_store` path to
1316/// avoid loading full TOML.  Otherwise loads full TOML per Card.
1317// TODO(issue:e60cd19d): tracing::error emit — Card 全域 Error Log pattern スイープで対応
1318pub fn find_with_store(store: &dyn CardStore, q: FindQuery) -> Result<Vec<Summary>, String> {
1319    // Fast path: lightweight query, no full-TOML load needed.
1320    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    // Full path: load entire TOML for where evaluation / arbitrary order_by.
1340    let all_rows = scan_cards(store, q.pkg.as_deref())?;
1341
1342    // Filter by where.
1343    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    // Sort.
1353    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    // Offset + limit.
1365    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// ───────────────────────────────────────────────────────────────
1376// Lineage walker
1377// ───────────────────────────────────────────────────────────────
1378//
1379// Cards form a tree (typically, not strictly a DAG) via the
1380// `metadata.prior_card_id` convention. `lineage()` walks that tree
1381// either up (toward ancestors) or down (toward descendants) or both,
1382// up to a configurable depth, optionally filtered by `prior_relation`.
1383//
1384// Up-walk is O(depth) — each step reads one parent Card.
1385// Down-walk is O(N_cards × depth) — we scan the whole store at each
1386// level. For the current scale (hundreds to low thousands of cards)
1387// this is fine; if the store grows we can build a prior_card_id index.
1388
1389/// Walk direction for `lineage`.
1390#[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/// Query parameters for `lineage`.
1412#[derive(Debug, Clone, Default)]
1413pub struct LineageQuery {
1414    pub card_id: String,
1415    pub direction: LineageDirection,
1416    /// Max traversal depth. Default 10.
1417    pub depth: Option<usize>,
1418    /// Include a per-node `stats` field (full [stats] section).
1419    pub include_stats: bool,
1420    /// If set, only edges whose `prior_relation` is in this list are
1421    /// followed.  The root is always included regardless.
1422    pub relation_filter: Option<Vec<String>>,
1423}
1424
1425/// One node in the lineage result.
1426///
1427/// `depth` is the signed distance from the root: negative for
1428/// ancestors (up-walk), 0 for the root, positive for descendants.
1429#[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/// One edge in the lineage result (child → parent, always).
1440#[derive(Debug, Clone)]
1441pub struct LineageEdge {
1442    pub from: String,
1443    pub to: String,
1444    pub relation: Option<String>,
1445}
1446
1447/// Full lineage walk result.
1448#[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
1458/// Extract the lineage fields from a Card JSON.
1459/// Returns (prior_card_id, prior_relation).
1460fn 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
1473/// Build a LineageNode from a loaded CardRow at a given depth.
1474fn 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
1491/// Check whether `relation` passes the relation_filter (None means no
1492/// filter, which always passes).
1493fn 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
1503/// Full in-memory card index with forward and reverse lineage maps.
1504struct CardIndex {
1505    /// card_id → CardRow
1506    cards: std::collections::HashMap<String, CardRow>,
1507    /// parent card_id → Vec<child card_id> (reverse lineage index)
1508    children: std::collections::HashMap<String, Vec<String>>,
1509}
1510
1511/// Load all Cards in the store once, keyed by card_id.
1512/// Also builds a reverse index (parent → children) so that
1513/// `walk_down` is O(result_size) instead of O(N_cards × depth).
1514fn 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
1532/// Scan all Cards in the store, loading full TOML for each. When
1533/// `pkg_filter` is provided, only that pkg subdir is scanned. Shared
1534/// between `find` and `load_card_index`.
1535fn 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
1546/// Invariant context passed through the lineage walkers.
1547struct LineageCtx<'a> {
1548    index: &'a CardIndex,
1549    relation_filter: &'a Option<Vec<String>>,
1550    include_stats: bool,
1551    max_depth: usize,
1552}
1553
1554/// Mutable accumulator for one lineage walk.
1555struct LineageAccum {
1556    nodes: Vec<LineageNode>,
1557    edges: Vec<LineageEdge>,
1558    visited: std::collections::HashSet<String>,
1559    truncated: bool,
1560}
1561
1562/// Walk ancestors via `metadata.prior_card_id`.
1563fn 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    // Depth exhausted but another unwalked parent exists → truncated.
1593    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
1604/// Walk descendants using the reverse index (parent → children),
1605/// breadth-first.  O(result_size) instead of O(N_cards × depth).
1606fn 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    // Frontier still has nodes but depth is exhausted: check for
1644    // unwalked children at the next level.
1645    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
1666/// Walk the lineage tree from `q.card_id` in `store`.
1667pub 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
1707/// Render a LineageResult as JSON for the service layer.
1708pub 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
1750// ───────────────────────────────────────────────────────────────
1751// Samples sidecar: per-case detail written alongside a Card as
1752// `{pkg}/{card_id}.samples.jsonl`. Write-once to preserve Card
1753// immutability: once a Card has a samples file, it cannot be
1754// rewritten — mismatched per-case data would break auditability.
1755// ───────────────────────────────────────────────────────────────
1756
1757// ───────────────────────────────────────────────────────────────
1758// Card import: copy Card files from an external directory into the
1759// local cards store. Used by `alc_card_install` (Card Collections)
1760// and by `alc_pkg_install` (Pkg-bundled cards/).
1761// ───────────────────────────────────────────────────────────────
1762
1763/// Import Card files into `store` from `source_dir` under `pkg`.
1764///
1765/// Copies `*.toml` and `*.samples.jsonl` files. Existing cards with the
1766/// same id are skipped (first-writer wins — Card immutability).
1767///
1768/// Returns `(imported, skipped)` card_id lists.
1769pub 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        // Samples are optional — best-effort.
1797        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
1815/// Write per-case samples to `{card_id}.samples.jsonl` (write-once).
1816///
1817/// Each `samples` entry is serialized as one compact JSON line.
1818/// Fails if a samples file already exists for this card — mirrors
1819/// the immutability guarantee of Cards themselves.
1820pub 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/// Query parameters for `read_samples`.
1872#[derive(Debug, Default, Clone)]
1873pub struct SamplesQuery {
1874    /// Skip this many matched rows (after `where` filtering).
1875    pub offset: usize,
1876    /// Max matched rows to return.
1877    pub limit: Option<usize>,
1878    /// Optional `where` predicate applied to each sample row.
1879    /// The row JSON is the full line object (no section wrapping).
1880    pub where_: Option<Predicate>,
1881}
1882
1883/// Read per-case samples from `{card_id}.samples.jsonl`.
1884///
1885/// Streams the JSONL file line by line; rows are parsed, optionally
1886/// filtered by `q.where_`, then paged by `offset` + `limit`.  Offset
1887/// applies to the **post-filter** stream, matching Prisma/SQL
1888/// semantics.
1889///
1890/// Returns an empty Vec if no samples file exists (Cards without
1891/// per-case details are the common case, not an error).
1892// TODO(issue:e60cd19d): tracing::error emit — Card 全域 Error Log pattern スイープで対応
1893pub 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
1930// ═══════════════════════════════════════════════════════════════
1931// FileCardStore — default backend.
1932// ═══════════════════════════════════════════════════════════════
1933//
1934// Stores Cards as TOML files under `{root}/{pkg}/{card_id}.toml`,
1935// samples as `{root}/{pkg}/{card_id}.samples.jsonl`, and the alias
1936// table as `{root}/_aliases.toml`.
1937//
1938// `root` is provided at construction time via `new(root)`; callers
1939// (typically the service layer) resolve it from the `AppDir`
1940// abstraction. Tests use a tempdir via `new(tmpdir)`.
1941
1942/// File-backed implementation of [`CardStore`].
1943pub struct FileCardStore {
1944    root: PathBuf,
1945}
1946
1947impl FileCardStore {
1948    /// Construct a store rooted at an explicit path.
1949    pub fn new(root: PathBuf) -> Self {
1950        Self { root }
1951    }
1952
1953    /// Return the root directory this store writes under.
1954    pub fn root(&self) -> &Path {
1955        &self.root
1956    }
1957
1958    // ─── Thin `self` delegations to the `*_with_store` free fns ────
1959    //
1960    // These let callers that hold an `Arc<FileCardStore>` (bridge/data
1961    // register_card closures, service layer) invoke domain logic via
1962    // instance methods without re-importing the `_with_store` free
1963    // functions. Semantics are identical to the free-fn variants.
1964
1965    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    /// Returns the absolute path to the per-pkg subdirectory,
2024    /// creating it when missing. Validates `pkg` to prevent path
2025    /// traversal.
2026    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    /// Path of the global alias table.
2036    fn aliases_path(&self) -> PathBuf {
2037        self.root.join("_aliases.toml")
2038    }
2039
2040    /// Path of the samples sidecar for `card_id`. Errors if the
2041    /// Card itself does not exist — samples without a parent Card
2042    /// are meaningless and we refuse to create orphans.
2043    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        // Ensure the cards root exists so aliases can be written to
2205        // a brand-new store (mirrors the behaviour of `cards_dir()`).
2206        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// ═══════════════════════════════════════════════════════════════
2324// Event Publisher Port — Card sink fan-out (v1)
2325// ═══════════════════════════════════════════════════════════════
2326//
2327// Storage port (`CardStore` / `FileCardStore`) stays pure CRUD. The
2328// Event publisher port below mirrors every successful Card write to a
2329// set of subscriber backends configured via the `ALC_CARD_SINKS`
2330// environment variable. Fan-out is best-effort and strictly serial;
2331// subscriber failures never propagate to the primary Card API.
2332
2333// ─── CardEvent / CardEventKind ─────────────────────────────────
2334
2335/// A Card-level event emitted from the write path.
2336///
2337/// Each variant carries the already-serialized payload text so that
2338/// subscribers can persist the exact bytes that were written to the
2339/// primary store (byte-for-byte parity).
2340#[derive(Debug, Clone)]
2341pub enum CardEvent {
2342    /// A brand-new Card was written.
2343    Created {
2344        pkg: String,
2345        card_id: String,
2346        toml_text: String,
2347    },
2348    /// An existing Card had new top-level keys merged in.
2349    Appended { card_id: String, toml_text: String },
2350    /// A samples JSONL sidecar was written for `card_id`.
2351    SamplesWritten { card_id: String, jsonl_text: String },
2352    /// The global alias table was rewritten.
2353    AliasesWritten { toml_text: String },
2354}
2355
2356/// Lightweight discriminant for `CardEvent`. Used as a `HashMap` key in
2357/// `SubscriberStats` so that ok/err counters can be tracked per event
2358/// kind without holding the full payload.
2359#[derive(Clone, Copy, Debug, Hash, Eq, PartialEq)]
2360pub enum CardEventKind {
2361    Created,
2362    Appended,
2363    SamplesWritten,
2364    AliasesWritten,
2365}
2366
2367impl CardEventKind {
2368    /// Stable string tag used in `tracing` log fields.
2369    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    /// Short JSON key for the public `alc_stats` snapshot.
2379    /// Distinct from `as_str()` so that tracing logs keep their
2380    /// verbose form while the JSON surface stays compact.
2381    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    /// All kinds in stable display order. Used by `SubscriberHealthRow`
2391    /// to emit all four counter keys even when a counter is zero, so
2392    /// that downstream consumers can rely on field presence.
2393    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    /// Return the `CardEventKind` discriminant for this event.
2411    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
2421// ─── CardSubscriber trait ──────────────────────────────────────
2422
2423/// A downstream backend that receives `CardEvent`s in best-effort,
2424/// serial fan-out order.
2425///
2426/// Implementations must be `Send + Sync` so that the bus can hold
2427/// `Arc<dyn CardSubscriber>` safely across threads.
2428pub trait CardSubscriber: Send + Sync {
2429    /// Handle one event. Returning `Err` records a failure in
2430    /// `SubscriberStats` and emits a `tracing::warn!`, but does not
2431    /// propagate to the caller of the `_with_store` API.
2432    fn on_event(&self, ev: &CardEvent) -> Result<(), String>;
2433
2434    /// Canonical identity URI for this subscriber. Used as the key in
2435    /// `SubscriberStats` and as the match target for
2436    /// `CardEventBus::publish_to`.
2437    fn describe(&self) -> String;
2438
2439    /// Best-effort check for whether `card_id` already exists in this
2440    /// subscriber. Used by `alc_card_sink_backfill` to skip cards that
2441    /// are already mirrored (drift-safe: we never overwrite).
2442    ///
2443    /// Default implementation returns `Ok(false)` so subscribers that
2444    /// cannot cheaply answer (e.g. future network-backed sinks) always
2445    /// get the push. Override when a cheap local check is possible.
2446    fn has_card(&self, _card_id: &str) -> Result<bool, String> {
2447        Ok(false)
2448    }
2449}
2450
2451// ─── SubscriberStats ───────────────────────────────────────────
2452
2453/// Most recent delivery failure for a single subscriber. Exposed via
2454/// `SubscriberHealthRow.last_error` in the `alc_stats` JSON snapshot.
2455#[derive(Debug, Clone, Serialize)]
2456pub struct LastError {
2457    pub kind: CardEventKind,
2458    pub msg: String,
2459    pub ts_ms: u64,
2460}
2461
2462/// Per-subscriber counter state. Held inside `SubscriberStats` under a
2463/// `Mutex`; `snapshot` clones the fields into an owned `SubscriberHealthRow`
2464/// while the lock is held, so the lock window stays short.
2465#[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/// Process-wide per-subscriber statistics, keyed by subscriber URI
2473/// (the value returned by `CardSubscriber::describe`).
2474#[derive(Default, Debug)]
2475pub struct SubscriberStats {
2476    inner: Mutex<HashMap<String, PerSubscriber>>,
2477}
2478
2479impl SubscriberStats {
2480    /// Record a successful event delivery.
2481    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    /// Record a delivery failure together with the error message.
2489    /// The failure kind, message, and timestamp overwrite `last_error`
2490    /// — there is no ring buffer; only the most recent failure is kept.
2491    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    /// Take a point-in-time snapshot of all subscribers. The returned
2504    /// `Vec` is owned — the internal lock is released before this
2505    /// function returns, so callers can hold it freely.
2506    ///
2507    /// All four `CardEventKind` keys (`created / appended / samples /
2508    /// aliases`) are always present in both `ok` and `err`, defaulting
2509    /// to 0 if no event of that kind has been recorded. This lets
2510    /// downstream consumers rely on field presence.
2511    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        // Stable output ordering (by sink URI) so that the JSON dump is
2535        // deterministic across runs — useful for snapshot tests.
2536        rows.sort_by(|a, b| a.sink.cmp(&b.sink));
2537        rows
2538    }
2539}
2540
2541/// Unix-epoch milliseconds used by `LastError.ts_ms`. Uses
2542/// `unwrap_or_default` so no panic can escape even if the system
2543/// clock is misconfigured.
2544fn 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/// Snapshot row for a single subscriber, serialized directly into the
2552/// `alc_stats` JSON output as one element of the `card_sinks` array.
2553#[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
2561/// Public entry point: snapshot of all process-wide subscriber stats.
2562/// Wrapper around `event_bus().stats().snapshot()` so that downstream
2563/// crates (notably `algocline-app`) do not need a handle to the
2564/// `CardEventBus` singleton.
2565pub fn subscriber_stats_snapshot() -> Vec<SubscriberHealthRow> {
2566    event_bus().stats().snapshot()
2567}
2568
2569// ─── FileCardSubscriber — file-URI backend ─────────────────────
2570
2571/// Atomically write `bytes` to `dest` by staging to a unique
2572/// `{dest}.tmp.{pid}.{seq}` sibling and renaming. The `{pid}.{seq}`
2573/// suffix prevents concurrent writers (same or different processes)
2574/// from colliding on the tmp path.
2575fn 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
2591/// Canonical `file://` URI for a local directory path. The result is
2592/// the inverse of [`decode_file_uri_path`] — the pair round-trips
2593/// through the `ALC_CARD_SINKS` env spec.
2594fn 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
2610/// A subscriber that mirrors events to a local directory using the
2611/// same two-tier layout as [`FileCardStore`]:
2612///
2613/// - `{root}/{pkg}/{card_id}.toml` — Card TOML
2614/// - `{root}/{pkg}/{card_id}.samples.jsonl` — samples sidecar
2615/// - `{root}/_aliases.toml` — global alias table
2616pub struct FileCardSubscriber {
2617    root: PathBuf,
2618    uri: String,
2619}
2620
2621impl FileCardSubscriber {
2622    /// Construct a subscriber rooted at `root`. The canonical URI is
2623    /// computed once and returned from [`Self::describe`].
2624    pub fn new(root: PathBuf) -> Self {
2625        let uri = canonical_file_uri(&root);
2626        Self { root, uri }
2627    }
2628
2629    /// Scan the subscriber root for a Card with `card_id` under any
2630    /// `{pkg}` subdirectory. Returns `Ok(None)` when the root itself
2631    /// does not exist yet (subscribers are write-once lazy).
2632    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    /// Delegates to [`FileCardSubscriber::locate_card`]. A non-existent
2721    /// root (subscriber has never been written to) returns `Ok(false)`,
2722    /// which is the correct "backfill needed" answer.
2723    fn has_card(&self, card_id: &str) -> Result<bool, String> {
2724        Ok(self.locate_card(card_id)?.is_some())
2725    }
2726}
2727
2728// ─── LogSinkCardSubscriber — fan-out into per-session LogSink ──
2729
2730/// Fan-out `CardEvent`s into per-session `LogSink` ring buffers.
2731///
2732/// Process-wide singleton accessed via [`log_sink_subscriber`]. Each Session
2733/// registers its own `LogSink` on start-up via [`register_log_sink`] and
2734/// unregisters on `Drop` of the returned [`LogSinkRegistration`] guard.
2735///
2736/// # Concurrency
2737///
2738/// `Send + Sync`. Internal state is `Mutex<Vec<(u64, LogSink)>>` +
2739/// `AtomicU64`. `on_event` uses the same snapshot-clone-then-iterate
2740/// pattern as [`CardEventBus::publish`]: sinks are cloned under the lock
2741/// then iterated with the lock released, so registrations/unregistrations
2742/// from other threads never deadlock a fan-out in flight. The mutex uses
2743/// the crate-wide `unwrap_or_else(|p| p.into_inner())` policy so a
2744/// poisoned lock never propagates a panic.
2745#[derive(Debug)]
2746pub struct LogSinkCardSubscriber {
2747    sinks: Mutex<Vec<(u64, LogSink)>>,
2748    next_id: AtomicU64,
2749}
2750
2751impl LogSinkCardSubscriber {
2752    /// Fixed subscriber URI used by [`CardEventBus`] to key
2753    /// `SubscriberStats` and to guard against duplicate registration.
2754    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    /// Format a `CardEvent` into the message payload for a `LogEntry`.
2764    ///
2765    /// The payload body itself is intentionally omitted (ring buffer is
2766    /// cap=20 by design; full-payload archival is [`FileCardSubscriber`]'s
2767    /// job).
2768    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    /// Always returns `Ok(())`.
2794    ///
2795    /// Snapshots the sink list under `sinks.lock()`, releases the lock, then
2796    /// pushes a `LogEntry { source: "alc.card", level: "info", message: <fmt> }`
2797    /// into every snapshotted `LogSink`. Individual `LogSink::push` failures
2798    /// (poisoned per-sink mutex) are silently dropped, matching the ring-buffer
2799    /// best-effort policy in `algocline_core::recent_log`.
2800    ///
2801    /// # Concurrency
2802    ///
2803    /// Cancel-safe (no `.await`). Safe to call from any thread. Does not hold
2804    /// `self.sinks` across the fan-out loop, so re-entrant registration from
2805    /// within a `LogSink::push` implementation (currently impossible) would
2806    /// still not deadlock.
2807    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    /// Returns the fixed subscriber URI `"log-sink://alc.card"`.
2820    ///
2821    /// Used by [`CardEventBus`] to key `SubscriberStats` and by
2822    /// [`log_sink_subscriber`] initializer to guard against duplicate
2823    /// registration on the bus.
2824    fn describe(&self) -> String {
2825        Self::URI.to_string()
2826    }
2827}
2828
2829/// RAII guard that unregisters a `LogSink` from the singleton
2830/// [`LogSinkCardSubscriber`] when dropped.
2831///
2832/// Returned by [`register_log_sink`]. The guard owns a strong `Arc` to
2833/// the singleton subscriber and the monotonic `id` assigned at registration
2834/// time. `Drop` removes the matching `(id, LogSink)` entry via
2835/// `Vec::retain`.
2836///
2837/// # Concurrency
2838///
2839/// `Send + Sync`. `Drop::drop` acquires the subscriber's `sinks` mutex
2840/// once, using the poison-recovery policy: a panic in another thread that
2841/// left the lock poisoned does NOT prevent unregistration. The drop
2842/// races safely with concurrent `CardEventBus::publish` fan-outs because
2843/// the publisher takes a snapshot clone of the sinks vec before iterating;
2844/// an in-flight publish may or may not observe the just-unregistered sink,
2845/// but never deadlocks and never observes torn state.
2846///
2847/// Dropping the guard is O(N) in the number of registered sinks (typically
2848/// == active session count). Callers must not hold the returned guard
2849/// across `.await` points that could execute on a runtime worker also
2850/// running the fan-out — with the current single-shot lock scope this is
2851/// not a hazard.
2852#[derive(Debug)]
2853pub struct LogSinkRegistration {
2854    subscriber: Arc<LogSinkCardSubscriber>,
2855    id: u64,
2856}
2857
2858impl LogSinkRegistration {
2859    /// Numeric id assigned to this registration at
2860    /// [`register_log_sink`] time. Exposed for test verification of
2861    /// atomic id uniqueness; not intended as a stable API for callers.
2862    #[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
2879/// Register `sink` with the process-wide [`LogSinkCardSubscriber`] and
2880/// return an RAII guard.
2881///
2882/// The returned [`LogSinkRegistration`] MUST be kept alive for the lifetime
2883/// of the owning `Session`. Dropping the guard unregisters the sink; the
2884/// sink itself (`Arc<Mutex<VecDeque<LogEntry>>>`) is not mutated by the
2885/// drop.
2886///
2887/// This function is infallible. Internally it assigns a monotonically
2888/// increasing `u64` id via `AtomicU64::fetch_add(1, Ordering::Relaxed)`
2889/// (Relaxed is sufficient because the id only becomes visible to other
2890/// threads once the subsequent `sinks.lock()` push publishes it).
2891///
2892/// On first call, this transitively initializes the singleton
2893/// [`LogSinkCardSubscriber`] and registers it on the [`CardEventBus`] via
2894/// [`log_sink_subscriber`].
2895///
2896/// # Concurrency
2897///
2898/// Cancel-safe. `Send + Sync` arguments; safe to call from any thread
2899/// concurrently. Two concurrent calls receive distinct ids.
2900pub 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
2912/// Return the process-wide [`LogSinkCardSubscriber`] singleton.
2913///
2914/// On first call, initializes the singleton via
2915/// `OnceLock::get_or_init` and registers it on the [`CardEventBus`]
2916/// through [`CardEventBus::add_subscriber`]. Subsequent calls return the
2917/// already-initialized `Arc`.
2918///
2919/// # Concurrency
2920///
2921/// `OnceLock` guarantees the initializer runs exactly once even under
2922/// concurrent first-callers. The initializer calls `event_bus()` (a
2923/// distinct `OnceLock`); this cross-cell reentrance is safe because the
2924/// two cells are independent (see std::sync::OnceLock docs — reentrance
2925/// deadlock only applies to the same cell). Callers holding the returned
2926/// `Arc` may drop it freely; the singleton itself lives for `'static`.
2927pub 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            // Defensive de-dup guard: OnceLock guarantees single init,
2933            // but we still avoid pushing twice if another code path has
2934            // already registered a subscriber with the same URI.
2935            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
2947// ─── CardEventBus + OnceLock singleton ─────────────────────────
2948
2949/// Process-wide fan-out bus. Subscribers are registered once at startup
2950/// (from `ALC_CARD_SINKS`) and stored behind a `Mutex` so that tests
2951/// can swap them out via `replace_subscribers_for_test` without losing
2952/// the singleton identity.
2953pub struct CardEventBus {
2954    subscribers: Mutex<Vec<Arc<dyn CardSubscriber>>>,
2955    stats: Arc<SubscriberStats>,
2956}
2957
2958impl CardEventBus {
2959    /// Build a bus from an explicit subscriber list. Used both by the
2960    /// env loader and by `install_event_bus_for_test`.
2961    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    /// Shared handle to the per-subscriber counters.
2969    pub fn stats(&self) -> &Arc<SubscriberStats> {
2970        &self.stats
2971    }
2972
2973    /// Fan out `ev` to every registered subscriber serially. Subscriber
2974    /// failures are counted in `SubscriberStats` and logged but do not
2975    /// propagate.
2976    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    /// Deliver `ev` to exactly one subscriber identified by
2999    /// `target` URI. Returns `Err` when no subscriber matches or the
3000    /// subscriber itself fails (backfill path needs the caller to know).
3001    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    /// List every subscriber URI currently registered on the bus.
3029    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    /// Look up a subscriber by URI (as returned by `describe`). Returns
3035    /// `None` when no subscriber matches. Used by
3036    /// `alc_card_sink_backfill` to dispatch has_card checks against a
3037    /// specific sink.
3038    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    /// Append `sub` to the subscriber list.
3044    ///
3045    /// Unlike [`replace_subscribers_for_test`], this is a production API used
3046    /// by singleton subscribers (currently only [`log_sink_subscriber`]) to
3047    /// self-register after the bus is initialized.
3048    ///
3049    /// # Concurrency
3050    ///
3051    /// Acquires `self.subscribers.lock()` briefly to push. The lock scope
3052    /// does not include any subscriber callback, so this method cannot
3053    /// deadlock against an in-flight [`Self::publish`] (publish takes a
3054    /// snapshot clone under the same mutex, then releases before
3055    /// dispatching).
3056    ///
3057    /// Poisoned-lock recovery uses `unwrap_or_else(|p| p.into_inner())` in
3058    /// line with the rest of `CardEventBus`.
3059    ///
3060    /// This method does **not** de-duplicate. Callers that need at-most-once
3061    /// registration (e.g. [`log_sink_subscriber`]) rely on `OnceLock` at the
3062    /// call site to guarantee a single invocation.
3063    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    /// Replace the subscriber list in place while preserving singleton
3069    /// identity and shared `SubscriberStats`. Test-only.
3070    #[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    /// Reset the per-subscriber counters. Test-only helper.
3077    #[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
3086/// Return the process-wide `CardEventBus` singleton, initializing it
3087/// on the first call from the `ALC_CARD_SINKS` environment variable.
3088pub 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
3095/// Eagerly initialize the bus. Idempotent and safe to call multiple
3096/// times; intended for startup hooks (`main.rs`) so that subscriber
3097/// registration `tracing::info!` lines are emitted at boot rather than
3098/// on the first Card write.
3099pub 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/// Install a test-built bus. Fails once the singleton is already set.
3112#[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
3119/// Convenience wrapper: publish through the singleton.
3120pub fn publish(ev: CardEvent) {
3121    // In test builds, serialize publishing with the subscriber-test mutex so
3122    // that default-store tests running in parallel cannot inject events into
3123    // a subscriber test's mock while the mock is active.  The owning test
3124    // thread sets INSIDE_BUS_TEST to true so it does NOT try to acquire the
3125    // same lock it already holds (re-entrancy safe).
3126    #[cfg(test)]
3127    {
3128        let is_test_owner = INSIDE_BUS_TEST.with(|f| f.get());
3129        if !is_test_owner {
3130            // Block until any active subscriber test releases the lock.
3131            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/// Mutex used in tests to serialise subscriber-test setup against concurrent
3140/// publishes from default-store tests running in parallel.
3141#[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// Thread-local flag: set to `true` by the thread running inside
3148// `with_bus_subscribers` so that `publish` skips the gate (re-entrancy).
3149#[cfg(test)]
3150thread_local! {
3151    static INSIDE_BUS_TEST: std::cell::Cell<bool> = const { std::cell::Cell::new(false) };
3152}
3153
3154// ─── alc_card_sink_backfill ────────────────────────────────────
3155
3156/// Result of a [`card_sink_backfill`] run. One row per card the tool
3157/// touched (classified as pushed / skipped / failed / pushed_samples).
3158/// The `failed` entries carry the error message so an operator can
3159/// triage read-only mounts etc.
3160#[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
3169/// Backfill one subscriber (`sink` URI) from the primary store.
3170///
3171/// Steps:
3172/// 1. Look up the target subscriber on the event bus; fail fast if
3173///    the URI is not registered so the caller gets an immediate
3174///    error rather than a silent no-op.
3175/// 2. Enumerate every `(pkg, card_id)` pair from the default
3176///    [`CardStore`].
3177/// 3. For each pair, ask the subscriber whether it already has that
3178///    card (`CardSubscriber::has_card`). If yes → skipped (drift-safe,
3179///    no overwrite). If no → read the primary TOML and `publish_to`
3180///    the one target sink. Samples are mirrored the same way.
3181/// 4. `dry_run = true` short-circuits step 3: the report lists
3182///    what would have been pushed but no `publish_to` is issued,
3183///    so `SubscriberStats` does not increment.
3184///
3185/// `card_sink_backfill` operates with an injectable [`CardStore`]; tests
3186/// drive this directly against a tempdir-backed [`FileCardStore`] so they
3187/// never touch the user's real cards directory.
3188pub 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                // Unreadable / corrupt on primary. Do not panic; skip.
3232                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
3284// ─── ALC_CARD_SINKS env parser ─────────────────────────────────
3285
3286/// Read `ALC_CARD_SINKS` and build one subscriber per accepted spec.
3287/// Malformed entries are logged and skipped; duplicate URIs are
3288/// first-wins. Non-UTF8 values reject the whole env.
3289fn 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
3301/// Parse a `|`-separated list of subscriber URIs (the same format used
3302/// by `ALC_CARD_SINKS`). Extracted so tests can exercise the parser
3303/// without touching process environment.
3304fn 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
3328/// Parse one subscriber spec. v1 only accepts `file:///absolute/path`.
3329fn parse_subscriber_spec(spec: &str) -> Option<Arc<dyn CardSubscriber>> {
3330    // scheme required
3331    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    // scheme-specific: must start with `//`
3342    let Some(after_slash) = rest.strip_prefix("//") else {
3343        tracing::error!(spec, "file URI missing '//'");
3344        return None;
3345    };
3346    // split authority / path. path begins with '/'.
3347    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
3365/// Decode the path portion of a `file://` URI into a `PathBuf`.
3366///
3367/// Unix: a leading `/` is preserved (absolute path).
3368/// Windows: the leading `/` is stripped so that `/C:/a/b` becomes
3369/// `C:/a/b`.
3370fn decode_file_uri_path(encoded: &str) -> Option<PathBuf> {
3371    let decoded = percent_decode(encoded)?;
3372    #[cfg(windows)]
3373    {
3374        // Strip the leading slash so "/C:/foo" -> "C:/foo".
3375        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
3384/// Percent-decode a URI path segment. Returns `None` on invalid or
3385/// truncated `%XX` sequences.
3386fn 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    // ─── RunSection unit tests (Phase 1-B) ─────────────────────────
3428
3429    /// All three `RunStatus` tokens must round-trip successfully via
3430    /// `RunSection::from_json`.
3431    #[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    /// An unknown status token must be rejected with an error message
3449    /// enumerating the accepted values.  Downstream Lua callers rely on
3450    /// the tokens `succeeded, failed, skipped` appearing verbatim in the
3451    /// diagnostic so users can spot the typo without opening the docs.
3452    #[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    /// Absence of the top-level `run` field must yield `Ok(None)` so
3463    /// existing Card creation call sites (no `run` field) are unaffected
3464    /// by the new `[run]` schema.
3465    #[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    /// Only `status` is required; when `reason` / `action` are omitted,
3473    /// the parsed struct carries `None` and TOML serialization drops the
3474    /// fields entirely (`skip_serializing_if`).
3475    #[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    /// A fully populated `[run]` section must round-trip every field.
3485    #[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    // ─── load_full [run] section round-trip (Phase 1-C) ────────────
3501
3502    /// A Card TOML file that carries a `[run]` section on disk must
3503    /// surface every subfield (`status` / `reason` / `action`) in the
3504    /// `CardRow.full` JSON produced by `load_full`.  `load_full` is a
3505    /// raw `toml::from_str` + `toml_to_json` pass-through, so this test
3506    /// locks in the guarantee that Phase 1-B's on-disk `[run]` shape
3507    /// survives round-trip without any typed schema wiring at the
3508    /// loader boundary.
3509    #[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        // Write the Card TOML directly so we exercise the on-disk
3517        // representation Phase 1-B produces, rather than routing through
3518        // `create_with_store` (which would obscure the wire shape).
3519        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    /// Backward compat (Phase 1-C): a Card TOML file lacking `[run]`
3563    /// must load cleanly with no `run` key in the JSON projection, so
3564    /// existing Cards written before Phase 1-B remain unaffected by the
3565    /// new schema.
3566    #[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); // newer first
3677        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        // Expect: {pkg}_{model}_{YYYYMMDDTHHMMSS}_{hash6}
3719        // After removing the pkg prefix, there should be a segment
3720        // containing 'T' separating date and time.
3721        let tail = id.strip_prefix(&format!("{pkg}_")).unwrap();
3722        let parts: Vec<&str> = tail.split('_').collect();
3723        // parts = [model_short, YYYYMMDDTHHMMSS, hash6]
3724        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        // All other positions are digits
3736        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    // ─── P1: append ────────────────────────────────────────────
3771
3772    #[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        // Persisted
3799        let got = get_with_store(&store, &id).unwrap().unwrap();
3800        assert_eq!(got["caveats"]["notes"], json!("rescored after fix"));
3801        // Existing field untouched
3802        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        // Verify original value still there
3823        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    // ─── P1: alias_set / alias_list ────────────────────────────
3836
3837    #[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        // Rebind to a new card
3855        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    // ─── find + where DSL ───────────────────────────────────────
3879
3880    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        // scenario eq via nested object
3925        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        // gte operator
3942        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        // limit
3958        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        // implicit eq on sparse stats field
4009        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        // _or
4024        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        // _not
4041        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        // in operator
4055        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        // exists false (sparse field missing on haiku card? all have it, so test on
4071        // a field that only some have)
4072        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        // Tiebreak by card_id ascending
4133        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        // Best is 0.5, after offset=1 we start at 0.4 then 0.3.
4166        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        // contains: matches substring anywhere
4228        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        // starts_with: matches only the prefix
4242        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        // string ops on missing field → false
4257        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        // string ops on non-string field → false
4271        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    // ─── lineage ───────────────────────────────────────────────
4314
4315    /// Helper: create a child Card pointing at a parent with a relation.
4316    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        // a → b → c (c is newest; b points at a; c points at b)
4345        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        // a has two children b, c; c has one child d.
4387        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        // root + b + c + d = 4 nodes
4413        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    // ─── samples sidecar ───────────────────────────────────────
4501
4502    // Isolated `FileCardStore::new(tempdir)` sidesteps the shared-root race
4503    // in `find_card_locator`; see `read_samples_empty_when_absent` for the
4504    // full root-cause write-up.
4505    #[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        // offset + limit
4533        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    // Previously used `create` / `read_samples` (default `~/.algocline/cards/`
4559    // store). Under `cargo test --workspace` parallel runs, `find_card_locator`
4560    // scans the shared root with `fs::read_dir(...).flatten()` which silently
4561    // drops transient I/O errors — on macOS APFS, a concurrent `remove_dir_all`
4562    // from another test's `cleanup(pkg)` could trigger that transient error and
4563    // cause this test's just-created pkg dir entry to be missed, propagating
4564    // `card '...' not found` up through `samples_path` → `read_samples`.
4565    //
4566    // Isolating via `FileCardStore::new(tempdir)` + `_with_store` variants
4567    // sidesteps the shared-root race entirely. Same pattern as
4568    // `custom_root_file_store_roundtrip` / `test_fanout_concurrent_*`.
4569    #[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        // Equality predicate: passed == true keeps 3 rows.
4602        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        // Nested comparator: score gte 0.5 keeps c0/c3/c4.
4619        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        // Offset applies AFTER filter: passed=true then skip 1 + limit 1 → c2.
4636        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    // ─── import_from_dir ───────────────────────────────────────
4689
4690    #[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        // Create a source card file
4698        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        // Create a matching samples file
4709        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        // Verify card was imported
4720        let got = get_with_store(&store, &card_id).unwrap().unwrap();
4721        assert_eq!(got["card_id"], json!(card_id));
4722
4723        // Verify samples were copied
4724        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        // Create a card in the store first
4734        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        // Try to import a card with the same id
4744        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        // Original card untouched
4759        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        // A TOML file without schema_version = "card/v0" should be skipped
4771        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    // ─── PathCardStore (FileCardStore rooted at a custom path) ──────
4783    //
4784    // Smoke test proving the trait boundary lets callers swap the
4785    // storage root without touching `~/.algocline/cards/`.
4786
4787    #[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        // create → get → list through the _with_store variants
4794        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        // Ensure a distinct store does not see the card (isolation check).
4816        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 + lookup scoped to the custom store
4822        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        // samples write/read roundtrip
4832        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    // ═══════════════════════════════════════════════════════════════
4842    // Event Publisher Port tests
4843    // ═══════════════════════════════════════════════════════════════
4844
4845    use std::sync::atomic::AtomicUsize;
4846    use std::sync::Barrier;
4847
4848    /// Serialize access to `std::env::set_var("ALC_CARD_SINKS", ...)` so
4849    /// env-touching tests do not race.
4850    fn env_lock() -> &'static Mutex<()> {
4851        static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
4852        LOCK.get_or_init(|| Mutex::new(()))
4853    }
4854
4855    /// RAII guard that clears `INSIDE_BUS_TEST` on drop, including the panic
4856    /// unwinding path. Without this, a panic inside `f` would leave the
4857    /// thread-local `true` on the cargo-test worker thread, so the next test
4858    /// assigned to the same worker would bypass `bus_test_gate` and corrupt
4859    /// concurrent subscriber mocks.
4860    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    /// Ensure the global bus is initialized and subscribers are cleared,
4868    /// then install `subs` on the singleton for the duration of a test.
4869    ///
4870    /// This function holds `bus_test_gate()` for its entire duration. Any
4871    /// concurrent `publish()` call from a parallel default-store test will
4872    /// block until we release the gate, preventing event contamination.
4873    /// The INSIDE_BUS_TEST thread-local is set so that publish calls made
4874    /// FROM THIS THREAD (inside `f`) skip the gate and proceed directly
4875    /// (re-entrancy safe).
4876    ///
4877    /// If the test spawns child threads that also publish, those children
4878    /// must set INSIDE_BUS_TEST to true themselves (see
4879    /// `test_fanout_concurrent_create_with_store`). Otherwise they block on
4880    /// the gate held by this owner thread and deadlock on join.
4881    fn with_bus_subscribers<F>(subs: Vec<Arc<dyn CardSubscriber>>, f: F)
4882    where
4883        F: FnOnce(&'static CardEventBus),
4884    {
4885        // Acquire the gate FIRST. While we wait, no one else holds the owner
4886        // role, and our INSIDE_BUS_TEST is still false, so this lock is safe.
4887        let _guard = bus_test_gate().lock().unwrap_or_else(|p| p.into_inner());
4888        // Now mark this thread as the bus-test owner so that publish() from
4889        // within the closure does not try to re-acquire bus_test_gate().
4890        // The RAII guard clears the flag on both normal return and unwind.
4891        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        // Leave the bus clean for the next test.
4898        bus.replace_subscribers_for_test(Vec::new());
4899        bus.reset_stats_for_test();
4900        // _owner drops -> INSIDE_BUS_TEST = false (panic-safe)
4901        // _guard drops -> bus_test_gate released
4902    }
4903
4904    /// In-memory subscriber used for deterministic fan-out assertions.
4905    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    // ─── Bus lifetime ─────────────────────────────────────────
4939
4940    #[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            // Should not panic; publish is a pure no-op when empty.
4951            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        // Reaching here without panic is success.
4965    }
4966
4967    // ─── Fan-out core ─────────────────────────────────────────
4968
4969    #[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            // After create the subscriber must have the card so append can locate it.
5009            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        // Build a pre-existing source tree (a previous run's output).
5064        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            // Create the card BEFORE the subscriber knows about it. To do that,
5135            // swap the subscriber out so create does not mirror, then swap in.
5136            bus.replace_subscribers_for_test(Vec::new());
5137            let (id, _) =
5138                create_with_store(&store, json!({ "pkg": { "name": "missing_append_pkg" } }))
5139                    .unwrap();
5140            // Re-install the subscriber; it has no mirror of the card.
5141            bus.replace_subscribers_for_test(vec![fs_sub.clone()]);
5142
5143            // The append call must succeed on the primary; the subscriber
5144            // will record an error because locate_card returns None.
5145            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                // Primary call must still succeed despite subscriber failure.
5175                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    // ─── SubscriberStats JSON shape tests (Subtask 2) ──────────
5191
5192    #[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            // All four keys must be present (may be 0).
5216            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        // Regression guard: per-call SubscriberStats creation would
5296        // have reset the counter between create_with_store invocations.
5297        // Verify that counters accumulate across 3 independent calls
5298        // against the global bus's stats handle.
5299        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            // Use the public snapshot entry point to exercise the
5314            // same path that AppService::stats uses.
5315            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        // Serialize a minimal row and verify JSON field shape.
5331        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        // Invoke atomic_write against a fresh dir and capture the tmp
5358        // filename left on disk by forcing rename to happen on an
5359        // already-nonexistent dest. We simulate by writing to a path
5360        // and then inspecting the parent dir during the operation —
5361        // since atomic_write removes tmp on success, we instead check
5362        // the suffix format by constructing it the same way.
5363        let pid = process::id();
5364        let sample = format!("whatever.tmp.{pid}.0");
5365        // Regex-style match without the regex crate dependency:
5366        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        // Real atomic_write round-trip — must not panic and must leave
5372        // the dest file in place with the written bytes.
5373        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    // ─── describe / env parser ───────────────────────────────
5380
5381    #[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        // Parse the URI back and confirm the resolved path matches.
5389        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        // env-touching — serialized by env_lock() to avoid races with
5410        // any other env-reading test in this binary.
5411        let _g = env_lock().lock().unwrap_or_else(|p| p.into_inner());
5412        let prev = std::env::var("ALC_CARD_SINKS").ok();
5413        // SAFETY: test-only single-threaded env mutation under mutex.
5414        unsafe {
5415            std::env::set_var("ALC_CARD_SINKS", "");
5416        }
5417        let subs = load_subscribers_from_env();
5418        assert!(subs.is_empty());
5419        // restore
5420        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        // Windows canonicalization re-emits the same URI.
5463        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        // `file:///tmp/a:b` — colon inside the path component is a literal.
5477        #[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            // On Windows the colon shows up as a drive letter separator.
5485            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        // Exercised through `load_subscribers_from_env` via NotUnicode.
5518        // We cannot easily set a non-UTF8 env var cross-platform inside
5519        // a unit test, so we verify the error path indirectly: the
5520        // parser only consumes `String` which is UTF-8 by construction,
5521        // and the env reader branches on VarError::NotUnicode. To keep
5522        // the test meaningful, verify that percent-decoded non-UTF8
5523        // bytes are rejected (closest structural analogue).
5524        // `%C3%28` is an invalid UTF-8 two-byte sequence.
5525        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    // ═══════════════════════════════════════════════════════════════
5537    // Concurrency tests (concurrency-analysis.md §2)
5538    // ═══════════════════════════════════════════════════════════════
5539
5540    #[test]
5541    fn test_oncelock_init_race_single_winner() {
5542        // N threads call event_bus() concurrently; all must observe the
5543        // same singleton pointer.
5544        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        // Populate some value so the recovered inner map is non-empty.
5599        stats.record_ok("mock://poison", CardEventKind::Created);
5600
5601        // Poison the Mutex.
5602        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        // Follow-up accessors must not hang and must return the prior value.
5610        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        // Further writes must also succeed (via unwrap_or_else).
5616        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        // N threads build tmp suffix strings via the same atomic and
5625        // all suffixes must differ.
5626        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                // Collect the leaf filename for uniqueness.
5637                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        // Additionally confirm suffix format by invoking atomic_write
5643        // again and parsing the tmp we leave on a forced failure.
5644    }
5645
5646    #[test]
5647    fn test_atomic_tmp_seq_wraps_without_panic() {
5648        // Isolated AtomicU64 at the boundary of wrap-around. fetch_add
5649        // is documented to wrap without panic.
5650        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        // 2 threads write the same dest from different tmp names. On
5662        // POSIX the late writer wins; either way the dest must be
5663        // observable and contain one of the two payloads.
5664        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                // On POSIX both should succeed — rename is atomic but allowed.
5682                h.join().unwrap().unwrap();
5683                saw_ok += 1;
5684            }
5685            #[cfg(not(unix))]
5686            {
5687                // On Windows at least one must succeed.
5688                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                    // The parent holds `bus_test_gate()` for the entire
5713                    // `with_bus_subscribers` scope. Child threads must set
5714                    // INSIDE_BUS_TEST=true themselves so that publish()
5715                    // bypasses the gate instead of blocking on it (which
5716                    // would deadlock once the parent calls join()).
5717                    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    // ─── card_sink_backfill (Subtask 3) ────────────────────────
5751
5752    /// Primary-side fixture with N cards already written via the
5753    /// subscriber-free path so backfill has something to push.
5754    /// Returns (primary_dir_guard, store, card_ids).
5755    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            // Populate primary before the subscriber is live (temporarily drop it).
5783            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            // Subscriber is live during create, so it already has the card.
5809            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            // Stats must remain zero — dry run publishes nothing.
5842            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            // Manually place a drifted copy on the subscriber with sentinel text.
5862            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        // Subscriber A is already in-sync; Subscriber B is the backfill target.
5917        // Backfilling B must NOT re-deliver Created events to A.
5918        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                // Populate primary with subscriber A live (B temporarily absent).
5930                bus.replace_subscribers_for_test(vec![fa.clone()]);
5931                let (_primary, store, _ids) = backfill_primary_with_cards("backfill_bypass_pkg", 2);
5932                // Capture A's ok[created] count before backfill.
5933                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                // Now reinstall both subscribers and backfill only B.
5941                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        // Subscriber whose on_event always fails (no filesystem needed).
5982        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            // Reinstall the failing subscriber for the backfill phase.
6002            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        // Force init (no-op if already initialized by a prior test).
6025        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    // ─── LogSinkCardSubscriber unit tests (Phase 2-D) ─────────────
6035
6036    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        /// Empty `String` toml_text sentinel used to keep test payloads short
6044        /// while still exercising every `CardEvent` variant.
6045        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        // T1: single LogSink registration receives all 4 CardEvent variants,
6067        // each landing as `LogEntry { source: "alc.card", level: "info" }`.
6068        #[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        // T2: multiple LogSinks all receive fan-out.
6088        #[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        // T3: after RAII drop, the removed sink no longer receives events.
6109        #[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        // T4: describe() returns the canonical identity URI.
6139        #[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        // T5: on_event returns Ok(()) for every variant (never Err).
6147        #[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        // T6: message format literal for each of the 4 variants.
6156        #[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        // ─── 6 concurrency boundary tests (§2 of concurrency-analysis.md) ─
6207
6208        /// Pin the current thread as the bus-test owner (skips
6209        /// `bus_test_gate`) and hold the gate lock for the duration of the
6210        /// test. Callers that spawn child threads which publish must set
6211        /// `INSIDE_BUS_TEST` on each child thread too.
6212        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        // Concurrency #1: 8 threads publish concurrently through the bus,
6226        // subscriber-internal Mutex serializes all pushes without deadlock.
6227        #[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                // Verify SubscriberStats recorded exactly n_threads * per_thread
6262                // successful deliveries.
6263                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                // Reset for later tests.
6274                bus.replace_subscribers_for_test(Vec::new());
6275                bus.reset_stats_for_test();
6276            });
6277        }
6278
6279        // Concurrency #2: registration drop races with in-flight publish
6280        // through the bus; drop must not deadlock and post-drop entries
6281        // stop landing on the removed sink.
6282        #[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                // Let the publisher run briefly, then drop the registration.
6306                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                // Give the publisher a moment past the drop, then verify no new
6316                // entries appear.
6317                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        // Concurrency #3: 4 threads race on the singleton's first init;
6335        // OnceLock guarantees single Arc identity + single bus subscribe.
6336        #[test]
6337        fn test_log_sink_subscriber_oncelock_init_race() {
6338            with_bus_owner(|| {
6339                let bus = event_bus();
6340                // Force the singleton to already exist so we test the
6341                // observed OnceLock idempotence (a truly-first init requires
6342                // a fresh process which is not possible here).
6343                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                // Bus contains "log-sink://alc.card" exactly once.
6369                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        // Concurrency #4: publish loop, registration add, registration drop
6381        // interleave; no thread hangs and no torn state observed.
6382        #[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        // Concurrency #5: 16 threads register concurrently, all ids unique.
6434        #[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        // Concurrency #6: registration drop must recover from a poisoned
6455        // sinks Mutex left by another thread's panic while holding the lock.
6456        #[test]
6457        fn test_log_sink_registration_drop_poisoned_mutex() {
6458            // Use an isolated subscriber instance rather than the process-wide
6459            // singleton so the poison state does not leak into other tests.
6460            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            // Poison the mutex by panicking while holding the lock.
6473            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 the registration; must silently recover the poisoned lock
6486            // and remove our entry via retain.
6487            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    // ─── Card store fail → tracing::error emit tests (Phase 2-E) ──
6498
6499    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        /// Minimal `tracing::Subscriber` that captures error-level events
6509        /// emitted at target `alc.card`. Records the `message` field
6510        /// so tests can assert on the human-readable slug (e.g.
6511        /// `"write_new_card failed"`). No dependency on `tracing-subscriber`
6512        /// / `tracing-test` — implements the Subscriber trait directly.
6513        #[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        /// CardStore mock that fails every write. Read paths return
6549        /// enough state to reach the target write call.
6550        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                // Return Some so `alias_set_with_store` proceeds past the
6576                // existence check and reaches `write_aliases`.
6577                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                // Return Ok(false) so we proceed to the write call.
6599                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        /// `create_with_store` Err path must emit
6621        /// `tracing::error!(target: "alc.card", ..., "write_new_card failed")`.
6622        #[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        /// `write_samples_with_store` Err path must emit
6652        /// `tracing::error!(target: "alc.card", ..., "write_samples_text failed")`.
6653        #[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        /// `alias_set_with_store` Err path must emit
6680        /// `tracing::error!(target: "alc.card", ..., "write_aliases failed")`.
6681        #[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}