1use serde_json::Value as Json;
45
46use crate::card::{self, CardStore, FindQuery, OrderKey};
47
48#[derive(Debug)]
58pub enum CardContextSpec {
59 CardId(String),
61 Query {
63 pkg: String,
64 limit: usize,
66 },
67}
68
69pub 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
117fn sanitize_line(s: &str) -> String {
127 s.replace(['\n', '\r'], " ")
128}
129
130pub 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 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#[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 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 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 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 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 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 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 assert!(!out.contains("07/18"));
416 }
417
418 #[test]
419 fn format_sanitizes_newline_in_reason() {
420 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 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}