Skip to main content

algocline_engine/
card_context.rs

1//! Card context injection — resolves Card summaries for prompt injection.
2//!
3//! Phase 3-F MVP: `alc.llm(prompt, {card_context = ...})` opts key wires
4//! a small set of prior Cards into the LLM system prompt as an XML-like
5//! `<past_cards>` block.  This module is the pure-Rust core: Lua bridge
6//! wiring lives in `bridge/llm.rs`.
7//!
8//! ## Public API
9//!
10//! * [`CardContextSpec`] — 2-form spec (`CardId` or `Query { pkg, limit }`)
11//!   received from the Lua bridge after opts extraction.
12//! * [`resolve`] — spec → `Vec<Json>` full Card resolution against a
13//!   `CardStore`.  Silent Ok(empty) on not-found / empty query.
14//! * [`format_past_cards`] — infallible fixed-template renderer producing
15//!   the `<past_cards>...</past_cards>` block.
16//!
17//! ## Fixed format template (MVP)
18//!
19//! ```text
20//! <past_cards>
21//! Card MM/DD pkg=<pkg> card_id=<id> [run.status=<status>] Rating <val> reason=<reason>
22//! ...
23//! </past_cards>
24//! ```
25//!
26//! * Each Card on 1 line, `created_at` desc order (newest first)
27//! * `[run.status=...]` emitted only when the Card JSON carries `run.status`
28//! * `Rating <val>` emitted only when `stats.pass_rate` is present
29//!   (formatted with 1 decimal digit)
30//! * `reason=<reason>` emitted only when `run.reason` is present
31//! * `MM/DD` derived from RFC 3339 `created_at` (`&s[5..10]` with the
32//!   `-` separator rewritten to `/`); omitted when `created_at` is
33//!   missing or too short
34//! * Empty input slice → empty string (no `<past_cards>` wrapper)
35//!
36//! ## Error handling
37//!
38//! `resolve` returns `Result<Vec<Json>, String>`.  Underlying
39//! `card::get_with_store` / `card::find_with_store` errors are re-wrapped
40//! with a `"card_context resolve: "` prefix so the bridge layer can
41//! surface them via `tracing::warn!` before silently dropping the inject
42//! (Phase 3-F Done Criteria #4: silent no-op on resolution failure).
43
44use serde_json::Value as Json;
45
46use crate::card::{self, CardStore, FindQuery, OrderKey};
47
48/// Card context specification extracted from the Lua `card_context` opt.
49///
50/// Two forms are accepted at the Lua boundary:
51///
52/// * `card_context = "<card_id>"` → [`CardContextSpec::CardId`]
53/// * `card_context = { pkg = "<name>", limit = N }` → [`CardContextSpec::Query`]
54///
55/// The Lua bridge is responsible for the extraction; this module treats
56/// the parsed enum as-is.
57#[derive(Debug)]
58pub enum CardContextSpec {
59    /// Resolve a single Card by id.
60    CardId(String),
61    /// Resolve the most recent `limit` Cards for a given `pkg`.
62    Query {
63        pkg: String,
64        /// Number of Cards to fetch (default 5 chosen at the Lua bridge).
65        limit: usize,
66    },
67}
68
69/// Resolve a [`CardContextSpec`] against `store`, returning full Card
70/// JSON documents.
71///
72/// * [`CardContextSpec::CardId`] → 1-element `Vec<Json>` on hit, empty
73///   `Vec<Json>` on miss (miss is Ok, not Err).
74/// * [`CardContextSpec::Query`] → up to `limit` Card JSON values ordered
75///   by `created_at` descending; empty result set is Ok.
76///
77/// The query form loads full TOML for each hit via a second
78/// `get_with_store` call (N+1 fetch).  With the MVP default `limit = 5`
79/// this is well within I/O budget; if a future phase needs to raise the
80/// limit substantially we can add a `find_full_with_store` batching API.
81pub fn resolve(store: &dyn CardStore, spec: CardContextSpec) -> Result<Vec<Json>, String> {
82    match spec {
83        CardContextSpec::CardId(id) => {
84            match card::get_with_store(store, &id)
85                .map_err(|e| format!("card_context resolve: {e}"))?
86            {
87                Some(j) => Ok(vec![j]),
88                None => Ok(vec![]),
89            }
90        }
91        CardContextSpec::Query { pkg, limit } => {
92            let q = FindQuery {
93                pkg: Some(pkg),
94                where_: None,
95                order_by: vec![OrderKey {
96                    path: vec!["created_at".to_string()],
97                    desc: true,
98                }],
99                limit: Some(limit),
100                offset: None,
101            };
102            let summaries = card::find_with_store(store, q)
103                .map_err(|e| format!("card_context resolve: {e}"))?;
104            let mut out = Vec::with_capacity(summaries.len());
105            for s in summaries {
106                if let Some(j) = card::get_with_store(store, &s.card_id)
107                    .map_err(|e| format!("card_context resolve: {e}"))?
108                {
109                    out.push(j);
110                }
111            }
112            Ok(out)
113        }
114    }
115}
116
117/// Strip newline / carriage return from a Card-derived string so it
118/// stays on the single line the template invariant requires and cannot
119/// break out of the `<past_cards>` wrapper by injecting a `</past_cards>`
120/// line into a `reason` / `pkg` / etc. field.
121///
122/// Silent lossy transformation — the raw value is only consumed by the
123/// LLM prompt, and the stored Card TOML is unchanged. Only `\n` and
124/// `\r` are replaced (kept ASCII-safe, other control chars pass
125/// through so error messages read naturally).
126fn sanitize_line(s: &str) -> String {
127    s.replace(['\n', '\r'], " ")
128}
129
130/// Render a slice of resolved Card JSON values as the fixed
131/// `<past_cards>` XML-like block.
132///
133/// Infallible: unknown / missing fields are silently omitted from the
134/// per-Card line rather than surfaced as errors.  Empty input yields an
135/// empty string so the caller can unconditionally prefix without
136/// producing a stray `<past_cards></past_cards>` wrapper.
137pub fn format_past_cards(cards: &[Json]) -> String {
138    if cards.is_empty() {
139        return String::new();
140    }
141    let mut lines = Vec::with_capacity(cards.len() + 2);
142    lines.push("<past_cards>".to_string());
143    for c in cards {
144        let mut parts: Vec<String> = Vec::with_capacity(6);
145        parts.push("Card".to_string());
146        if let Some(ts) = c.get("created_at").and_then(|v| v.as_str()) {
147            // RFC 3339 slice `[5..10]` is "MM-DD"; use `str::get` for
148            // panic-safe UTF-8 boundary handling (non-ASCII created_at
149            // is theoretically possible from arbitrary TOML input, and
150            // a panic here would leak past the async closure boundary
151            // and violate the silent-no-op contract).
152            if let Some(mmdd) = ts.get(5..10) {
153                parts.push(mmdd.replace('-', "/"));
154            }
155        }
156        if let Some(pkg) = c
157            .get("pkg")
158            .and_then(|p| p.get("name"))
159            .and_then(|v| v.as_str())
160        {
161            parts.push(format!("pkg={}", sanitize_line(pkg)));
162        }
163        if let Some(id) = c.get("card_id").and_then(|v| v.as_str()) {
164            parts.push(format!("card_id={}", sanitize_line(id)));
165        }
166        if let Some(status) = c
167            .get("run")
168            .and_then(|r| r.get("status"))
169            .and_then(|v| v.as_str())
170        {
171            parts.push(format!("[run.status={}]", sanitize_line(status)));
172        }
173        if let Some(rate) = c
174            .get("stats")
175            .and_then(|s| s.get("pass_rate"))
176            .and_then(|v| v.as_f64())
177        {
178            parts.push(format!("Rating {rate:.1}"));
179        }
180        if let Some(reason) = c
181            .get("run")
182            .and_then(|r| r.get("reason"))
183            .and_then(|v| v.as_str())
184        {
185            parts.push(format!("reason={}", sanitize_line(reason)));
186        }
187        lines.push(parts.join(" "));
188    }
189    lines.push("</past_cards>".to_string());
190    lines.join("\n")
191}
192
193// ═══════════════════════════════════════════════════════════════
194// Unit tests
195// ═══════════════════════════════════════════════════════════════
196
197#[cfg(test)]
198mod tests {
199    use super::*;
200
201    use std::fs;
202    use std::path::PathBuf;
203
204    use crate::card::FileCardStore;
205
206    /// Write a Card TOML directly onto disk under `{root}/{pkg}/{card_id}.toml`.
207    ///
208    /// We bypass `create_with_store` so tests exercise the same on-disk
209    /// shape produced by Phase 1-B `[run]` and Phase 2 `[stats]` writers,
210    /// with no auto-inject side effects (schema_version etc. are already
211    /// baked into the test text).
212    fn write_card_toml(root: &std::path::Path, pkg: &str, card_id: &str, toml_text: &str) {
213        let pkg_dir = root.join(pkg);
214        fs::create_dir_all(&pkg_dir).expect("test setup: create pkg dir");
215        let path = pkg_dir.join(format!("{card_id}.toml"));
216        fs::write(&path, toml_text).expect("test setup: write card toml");
217    }
218
219    /// Build a minimal Card TOML fixture text with configurable fields.
220    fn fixture_toml(
221        pkg: &str,
222        card_id: &str,
223        created_at: &str,
224        run_status: Option<&str>,
225        run_reason: Option<&str>,
226        pass_rate: Option<f64>,
227    ) -> String {
228        let mut out = String::new();
229        out.push_str("schema_version = \"card/v0\"\n");
230        out.push_str(&format!("card_id = \"{card_id}\"\n"));
231        out.push_str(&format!("created_at = \"{created_at}\"\n"));
232        out.push_str("\n[pkg]\n");
233        out.push_str(&format!("name = \"{pkg}\"\n"));
234        if let Some(rate) = pass_rate {
235            out.push_str("\n[stats]\n");
236            out.push_str(&format!("pass_rate = {rate}\n"));
237        }
238        if let Some(status) = run_status {
239            out.push_str("\n[run]\n");
240            out.push_str(&format!("status = \"{status}\"\n"));
241            if let Some(reason) = run_reason {
242                out.push_str(&format!("reason = \"{reason}\"\n"));
243            }
244        }
245        out
246    }
247
248    fn store_with_root() -> (tempfile::TempDir, FileCardStore, PathBuf) {
249        let tmp = tempfile::tempdir().expect("test setup: tempdir");
250        let root = tmp.path().to_path_buf();
251        let store = FileCardStore::new(root.clone());
252        (tmp, store, root)
253    }
254
255    #[test]
256    fn resolve_card_id_found() {
257        let (_tmp, store, root) = store_with_root();
258        let toml_text = fixture_toml(
259            "cot",
260            "cot_test_found",
261            "2026-07-18T00:00:00Z",
262            Some("succeeded"),
263            None,
264            Some(4.5),
265        );
266        write_card_toml(&root, "cot", "cot_test_found", &toml_text);
267
268        let out = resolve(&store, CardContextSpec::CardId("cot_test_found".into()))
269            .expect("resolve must succeed for a known Card id");
270        assert_eq!(out.len(), 1, "one Card expected");
271        assert_eq!(
272            out[0].get("card_id").and_then(|v| v.as_str()),
273            Some("cot_test_found")
274        );
275    }
276
277    #[test]
278    fn resolve_card_id_not_found() {
279        let (_tmp, store, _root) = store_with_root();
280        let out = resolve(&store, CardContextSpec::CardId("nonexistent".into()))
281            .expect("resolve must return Ok(empty) for a missing Card, not Err");
282        assert!(out.is_empty(), "no Cards expected for missing id");
283    }
284
285    #[test]
286    fn resolve_query_returns_n_recent() {
287        let (_tmp, store, root) = store_with_root();
288        // 5 Cards ascending created_at; query limit=3 should return the 3 newest.
289        for i in 0..5 {
290            let card_id = format!("cot_bulk_{i}");
291            let ts = format!("2026-07-{:02}T00:00:00Z", 10 + i);
292            let toml_text = fixture_toml("cot", &card_id, &ts, Some("succeeded"), None, Some(4.0));
293            write_card_toml(&root, "cot", &card_id, &toml_text);
294        }
295
296        let out = resolve(
297            &store,
298            CardContextSpec::Query {
299                pkg: "cot".to_string(),
300                limit: 3,
301            },
302        )
303        .expect("resolve query must succeed");
304
305        assert_eq!(out.len(), 3, "limit=3 must cap the result");
306        // Expect descending by created_at → newest first (2026-07-14, -13, -12).
307        assert_eq!(
308            out[0].get("card_id").and_then(|v| v.as_str()),
309            Some("cot_bulk_4")
310        );
311        assert_eq!(
312            out[1].get("card_id").and_then(|v| v.as_str()),
313            Some("cot_bulk_3")
314        );
315        assert_eq!(
316            out[2].get("card_id").and_then(|v| v.as_str()),
317            Some("cot_bulk_2")
318        );
319    }
320
321    #[test]
322    fn resolve_query_empty_pkg() {
323        let (_tmp, store, root) = store_with_root();
324        // Populate a different pkg so the store is non-empty overall but
325        // the queried pkg yields nothing.
326        let toml_text = fixture_toml(
327            "other",
328            "other_only",
329            "2026-07-18T00:00:00Z",
330            None,
331            None,
332            None,
333        );
334        write_card_toml(&root, "other", "other_only", &toml_text);
335
336        let out = resolve(
337            &store,
338            CardContextSpec::Query {
339                pkg: "missing_pkg".to_string(),
340                limit: 5,
341            },
342        )
343        .expect("resolve on absent pkg must be Ok(empty), not Err");
344        assert!(out.is_empty(), "empty pkg must yield empty Vec");
345    }
346
347    #[test]
348    fn format_with_full_card() {
349        let card = serde_json::json!({
350            "card_id": "cot_full",
351            "created_at": "2026-07-18T06:15:00Z",
352            "pkg": { "name": "cot" },
353            "stats": { "pass_rate": 4.5 },
354            "run": { "status": "succeeded", "reason": "clean" },
355        });
356        let out = format_past_cards(&[card]);
357        assert!(out.starts_with("<past_cards>\n"), "must open with tag");
358        assert!(out.ends_with("\n</past_cards>"), "must close with tag");
359        assert!(out.contains("Card 07/18"), "MM/DD slice: {out}");
360        assert!(out.contains("pkg=cot"), "pkg segment: {out}");
361        assert!(out.contains("card_id=cot_full"), "card_id segment: {out}");
362        assert!(
363            out.contains("[run.status=succeeded]"),
364            "run.status segment: {out}"
365        );
366        assert!(out.contains("Rating 4.5"), "rating segment: {out}");
367        assert!(out.contains("reason=clean"), "reason segment: {out}");
368    }
369
370    #[test]
371    fn format_without_run_section() {
372        let card = serde_json::json!({
373            "card_id": "cot_norun",
374            "created_at": "2026-07-17T00:00:00Z",
375            "pkg": { "name": "cot" },
376            "stats": { "pass_rate": 3.2 },
377        });
378        let out = format_past_cards(&[card]);
379        assert!(out.contains("pkg=cot"), "pkg still emitted: {out}");
380        assert!(
381            out.contains("card_id=cot_norun"),
382            "card_id still emitted: {out}"
383        );
384        assert!(out.contains("Rating 3.2"), "rating still emitted: {out}");
385        assert!(
386            !out.contains("[run.status="),
387            "run.status must be omitted when absent: {out}"
388        );
389        assert!(
390            !out.contains("reason="),
391            "reason must be omitted when absent: {out}"
392        );
393    }
394
395    #[test]
396    fn format_empty_slice() {
397        let out = format_past_cards(&[]);
398        assert!(out.is_empty(), "empty slice must yield empty string");
399    }
400
401    #[test]
402    fn format_non_ascii_created_at_does_not_panic() {
403        // Non-ASCII in `created_at` is theoretically possible from
404        // arbitrary TOML input. `ts[5..10]` would panic on a multi-byte
405        // UTF-8 boundary; `ts.get(5..10)` returns `None` and skips the
406        // MM/DD segment silently.
407        let card = serde_json::json!({
408            "card_id": "abc",
409            "created_at": "AB\u{1f600}CDEFG-more",
410            "pkg": { "name": "cot" },
411        });
412        let out = format_past_cards(&[card]);
413        assert!(out.contains("card_id=abc"));
414        // MM/DD skipped because byte 5 is inside the 😀 codepoint.
415        assert!(!out.contains("07/18"));
416    }
417
418    #[test]
419    fn format_sanitizes_newline_in_reason() {
420        // A `reason` field carrying `\n</past_cards>...` must NOT break
421        // out of the wrapper — newlines are replaced with spaces so
422        // the 1-line-per-Card template invariant holds.
423        let card = serde_json::json!({
424            "card_id": "abc",
425            "created_at": "2026-07-18T00:00:00Z",
426            "pkg": { "name": "cot" },
427            "run": { "status": "failed", "reason": "line1\n</past_cards>\ninjected" },
428        });
429        let out = format_past_cards(&[card]);
430        // Exactly 3 lines: <past_cards> + 1 card line + </past_cards>.
431        assert_eq!(
432            out.lines().count(),
433            3,
434            "template must remain 3 lines, got: {out}"
435        );
436        assert!(out.contains("reason=line1 </past_cards> injected"));
437    }
438}