1use std::collections::BTreeMap;
22
23use serde::{Deserialize, Serialize};
24use sqlx::SqlitePool;
25use thiserror::Error;
26
27use crate::store::trace::TaskKind;
28use crate::store::StoreError;
29
30fn is_agent_loop(task_kind: &str) -> bool {
35 task_kind == TaskKind::Exploration.as_str()
36}
37
38#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
41pub struct BySplit {
42 pub key: String,
43 pub count: i64,
44}
45
46#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
47pub struct SpendSplit {
48 pub one_shot_usd_micros: i64,
49 pub agent_loop_usd_micros: i64,
50 pub by_task_kind: Vec<BySplit>,
54}
55
56impl SpendSplit {
57 pub fn total_usd_micros(&self) -> i64 {
58 self.one_shot_usd_micros + self.agent_loop_usd_micros
59 }
60}
61
62#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
63pub struct PhaseDuration {
64 pub phase: String,
65 pub wall_clock_ms: i64,
66 pub call_count: i64,
67}
68
69#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
71pub struct RunCard {
72 pub run_id: String,
73 pub started_at: i64,
74 pub finished_at: Option<i64>,
75 pub status: String,
76 pub triggered_by: String,
77 pub wall_clock_ms: Option<i64>,
78 pub total_findings: i64,
79 pub by_status: Vec<BySplit>,
80 pub by_cap: Vec<BySplit>,
81 pub by_origin: Vec<BySplit>,
82 pub by_lang: Vec<BySplit>,
83 pub by_repo: Vec<BySplit>,
84 pub spend: SpendSplit,
85 pub phase_durations: Vec<PhaseDuration>,
89 pub candidate_findings_total: i64,
95 pub by_candidate_status: Vec<BySplit>,
99 pub business_logic_templates_considered: i64,
100 pub business_logic_candidates_generated: i64,
101 pub business_logic_templates_skipped: i64,
102}
103
104#[derive(Debug, Error)]
105pub enum RunCardError {
106 #[error("run `{0}` not found")]
107 NotFound(String),
108 #[error(transparent)]
109 Store(#[from] StoreError),
110 #[error("database error: {0}")]
111 Sqlx(#[from] sqlx::Error),
112}
113
114pub async fn build_run_card(pool: &SqlitePool, run_id: &str) -> Result<RunCard, RunCardError> {
118 let run_row: Option<RunRow> = sqlx::query_as::<_, RunRow>(
119 "SELECT id, started_at, finished_at, status, triggered_by, wall_clock_ms \
120 FROM runs WHERE id = ?",
121 )
122 .bind(run_id)
123 .fetch_optional(pool)
124 .await?;
125 let run = run_row.ok_or_else(|| RunCardError::NotFound(run_id.to_string()))?;
126
127 let findings: Vec<FindingRow> = sqlx::query_as::<_, FindingRow>(
128 "SELECT id, repo, path, cap, status, finding_origin FROM findings WHERE run_id = ?",
129 )
130 .bind(run_id)
131 .fetch_all(pool)
132 .await?;
133
134 let payloads: Vec<PayloadRow> = sqlx::query_as::<_, PayloadRow>(
135 "SELECT p.finding_id, p.lang FROM payloads p \
136 JOIN findings f ON f.id = p.finding_id WHERE f.run_id = ?",
137 )
138 .bind(run_id)
139 .fetch_all(pool)
140 .await?;
141 let mut finding_lang: BTreeMap<String, String> = BTreeMap::new();
142 for p in payloads {
143 finding_lang.entry(p.finding_id).or_insert(p.lang);
144 }
145
146 let candidate_rows: Vec<CandidateRow> = sqlx::query_as::<_, CandidateRow>(
153 "SELECT status, COUNT(*) AS count \
154 FROM candidate_findings WHERE run_id = ? \
155 GROUP BY status",
156 )
157 .bind(run_id)
158 .fetch_all(pool)
159 .await?;
160 let mut by_candidate_status_map: BTreeMap<String, i64> = BTreeMap::new();
161 let mut candidate_findings_total: i64 = 0;
162 for row in candidate_rows {
163 by_candidate_status_map.insert(row.status, row.count);
164 candidate_findings_total += row.count;
165 }
166 let business_logic_row: BusinessLogicSummaryRow = sqlx::query_as::<_, BusinessLogicSummaryRow>(
167 "SELECT COUNT(*) AS templates_considered, \
168 COALESCE(SUM(generated_count), 0) AS candidates_generated, \
169 COALESCE(SUM(CASE WHEN skipped_count > 0 THEN 1 ELSE 0 END), 0) AS templates_skipped \
170 FROM business_logic_template_runs WHERE run_id = ?",
171 )
172 .bind(run_id)
173 .fetch_one(pool)
174 .await?;
175
176 let traces: Vec<TraceRow> = sqlx::query_as::<_, TraceRow>(
177 "SELECT t.task_kind, t.cost_usd_micros, t.started_at, t.finished_at \
178 FROM agent_traces t \
179 LEFT JOIN findings f ON f.id = t.finding_id \
180 WHERE f.run_id = ? \
181 OR t.started_at BETWEEN ? AND ?",
182 )
183 .bind(run_id)
184 .bind(run.started_at)
185 .bind(run.finished_at.unwrap_or(i64::MAX))
186 .fetch_all(pool)
187 .await?;
188
189 let mut by_status: BTreeMap<String, i64> = BTreeMap::new();
190 let mut by_cap: BTreeMap<String, i64> = BTreeMap::new();
191 let mut by_origin: BTreeMap<String, i64> = BTreeMap::new();
192 let mut by_repo: BTreeMap<String, i64> = BTreeMap::new();
193 let mut by_lang: BTreeMap<String, i64> = BTreeMap::new();
194 for f in &findings {
195 *by_status.entry(f.status.clone()).or_default() += 1;
196 *by_cap.entry(f.cap.clone()).or_default() += 1;
197 *by_origin.entry(f.finding_origin.clone()).or_default() += 1;
198 *by_repo.entry(f.repo.clone()).or_default() += 1;
199 let lang =
200 finding_lang.get(&f.id).cloned().unwrap_or_else(|| lang_from_path(&f.path).to_string());
201 *by_lang.entry(lang).or_default() += 1;
202 }
203
204 let mut spend = SpendSplit::default();
205 let mut by_task: BTreeMap<String, i64> = BTreeMap::new();
206 let mut phase_min: BTreeMap<String, i64> = BTreeMap::new();
207 let mut phase_max: BTreeMap<String, i64> = BTreeMap::new();
208 let mut phase_calls: BTreeMap<String, i64> = BTreeMap::new();
209 for t in &traces {
210 if is_agent_loop(&t.task_kind) {
211 spend.agent_loop_usd_micros += t.cost_usd_micros;
212 } else {
213 spend.one_shot_usd_micros += t.cost_usd_micros;
214 }
215 *by_task.entry(t.task_kind.clone()).or_default() += t.cost_usd_micros;
216 *phase_calls.entry(t.task_kind.clone()).or_default() += 1;
217 let start = t.started_at;
218 let finish = t.finished_at.unwrap_or(start);
219 phase_min.entry(t.task_kind.clone()).and_modify(|e| *e = (*e).min(start)).or_insert(start);
220 phase_max
221 .entry(t.task_kind.clone())
222 .and_modify(|e| *e = (*e).max(finish))
223 .or_insert(finish);
224 }
225 spend.by_task_kind = into_sorted_split(by_task);
226
227 let mut phase_durations: Vec<PhaseDuration> = Vec::new();
228 phase_durations.push(PhaseDuration {
229 phase: "static".to_string(),
230 wall_clock_ms: run.wall_clock_ms.unwrap_or(0),
231 call_count: 1,
232 });
233 for (phase, min) in phase_min {
234 let max = phase_max.get(&phase).copied().unwrap_or(min);
235 let calls = phase_calls.get(&phase).copied().unwrap_or(0);
236 phase_durations.push(PhaseDuration {
237 phase,
238 wall_clock_ms: (max - min).max(0),
239 call_count: calls,
240 });
241 }
242 phase_durations.sort_by(|a, b| a.phase.cmp(&b.phase));
243
244 Ok(RunCard {
245 run_id: run.id,
246 started_at: run.started_at,
247 finished_at: run.finished_at,
248 status: run.status,
249 triggered_by: run.triggered_by,
250 wall_clock_ms: run.wall_clock_ms,
251 total_findings: findings.len() as i64,
252 by_status: into_sorted_split(by_status),
253 by_cap: into_sorted_split(by_cap),
254 by_origin: into_sorted_split(by_origin),
255 by_lang: into_sorted_split(by_lang),
256 by_repo: into_sorted_split(by_repo),
257 spend,
258 phase_durations,
259 candidate_findings_total,
260 by_candidate_status: into_sorted_split(by_candidate_status_map),
261 business_logic_templates_considered: business_logic_row.templates_considered,
262 business_logic_candidates_generated: business_logic_row.candidates_generated,
263 business_logic_templates_skipped: business_logic_row.templates_skipped,
264 })
265}
266
267fn into_sorted_split(map: BTreeMap<String, i64>) -> Vec<BySplit> {
268 let mut out: Vec<BySplit> =
269 map.into_iter().map(|(key, count)| BySplit { key, count }).collect();
270 out.sort_by(|a, b| b.count.cmp(&a.count).then_with(|| a.key.cmp(&b.key)));
271 out
272}
273
274fn lang_from_path(path: &str) -> &'static str {
279 let lower = path.to_ascii_lowercase();
280 let ext = lower.rsplit_once('.').map(|(_, e)| e).unwrap_or("");
281 match ext {
282 "rs" => "rust",
283 "py" => "python",
284 "js" | "mjs" | "cjs" => "javascript",
285 "ts" | "tsx" => "typescript",
286 "go" => "go",
287 "java" => "java",
288 "rb" => "ruby",
289 "php" => "php",
290 "c" | "h" => "c",
291 "cc" | "cpp" | "cxx" | "hpp" | "hh" => "cpp",
292 "cs" => "csharp",
293 "swift" => "swift",
294 "kt" | "kts" => "kotlin",
295 _ => "unknown",
296 }
297}
298
299pub fn render_html(card: &RunCard) -> String {
304 let mut out = String::new();
305 out.push_str(&format!(
306 "<section><h2>Run {}</h2><dl><dt>Status</dt><dd>{}</dd>\
307 <dt>Triggered by</dt><dd>{}</dd>\
308 <dt>Started</dt><dd>{}</dd><dt>Finished</dt><dd>{}</dd>\
309 <dt>Wall clock</dt><dd>{} ms</dd>\
310 <dt>Total findings</dt><dd>{}</dd>\
311 <dt>Candidate findings</dt><dd>{}</dd>\
312 <dt>Business-logic templates</dt><dd>{} considered, {} generated candidate(s), {} skipped</dd></dl></section>",
313 escape_html(&card.run_id),
314 escape_html(&card.status),
315 escape_html(&card.triggered_by),
316 card.started_at,
317 card.finished_at.map(|v| v.to_string()).unwrap_or_else(|| "-".to_string()),
318 card.wall_clock_ms.unwrap_or(0),
319 card.total_findings,
320 card.candidate_findings_total,
321 card.business_logic_templates_considered,
322 card.business_logic_candidates_generated,
323 card.business_logic_templates_skipped,
324 ));
325 push_html_split(&mut out, "Status", &card.by_status);
326 push_html_split(&mut out, "Capability", &card.by_cap);
327 push_html_split(&mut out, "Origin", &card.by_origin);
328 push_html_split(&mut out, "Language", &card.by_lang);
329 push_html_split(&mut out, "Repository", &card.by_repo);
330 push_html_split(&mut out, "Candidate status", &card.by_candidate_status);
331 out.push_str(&format!(
332 "<section><h3>AI spend</h3>\
333 <p>One-shot: ${:.6} · Agent loop: ${:.6} · Total: ${:.6}</p>",
334 usd_from_micros(card.spend.one_shot_usd_micros),
335 usd_from_micros(card.spend.agent_loop_usd_micros),
336 usd_from_micros(card.spend.total_usd_micros()),
337 ));
338 out.push_str("<ul>");
339 for split in &card.spend.by_task_kind {
340 out.push_str(&format!(
341 "<li>{}: ${:.6}</li>",
342 escape_html(&split.key),
343 usd_from_micros(split.count),
344 ));
345 }
346 out.push_str("</ul></section>");
347 out.push_str("<section><h3>Phase wall clock</h3><ul>");
348 for phase in &card.phase_durations {
349 out.push_str(&format!(
350 "<li>{}: {} ms ({} call{})</li>",
351 escape_html(&phase.phase),
352 phase.wall_clock_ms,
353 phase.call_count,
354 if phase.call_count == 1 { "" } else { "s" },
355 ));
356 }
357 out.push_str("</ul></section>");
358 out
359}
360
361fn push_html_split(out: &mut String, title: &str, splits: &[BySplit]) {
362 out.push_str(&format!("<section><h3>{}</h3>", escape_html(title)));
363 if splits.is_empty() {
364 out.push_str("<p>-</p></section>");
365 return;
366 }
367 out.push_str("<ul>");
368 for s in splits {
369 out.push_str(&format!("<li>{}: {}</li>", escape_html(&s.key), s.count,));
370 }
371 out.push_str("</ul></section>");
372}
373
374fn escape_html(input: &str) -> String {
375 let mut out = String::with_capacity(input.len());
376 for ch in input.chars() {
377 match ch {
378 '&' => out.push_str("&"),
379 '<' => out.push_str("<"),
380 '>' => out.push_str(">"),
381 '"' => out.push_str("""),
382 '\'' => out.push_str("'"),
383 _ => out.push(ch),
384 }
385 }
386 out
387}
388
389pub fn render_markdown(card: &RunCard) -> String {
397 let mut out = String::new();
398 out.push_str(&format!("# Run {}\n\n", markdown_code(&card.run_id)));
399 out.push_str(&format!("- **Status**: {}\n", markdown_code(&card.status)));
400 out.push_str(&format!("- **Triggered by**: {}\n", markdown_code(&card.triggered_by)));
401 out.push_str(&format!("- **Started**: {}\n", card.started_at));
402 out.push_str(&format!(
403 "- **Finished**: {}\n",
404 card.finished_at.map(|v| v.to_string()).unwrap_or_else(|| "-".to_string())
405 ));
406 out.push_str(&format!("- **Wall clock**: {} ms\n", card.wall_clock_ms.unwrap_or(0)));
407 out.push_str(&format!("- **Total findings**: {}\n", card.total_findings));
408 out.push_str(&format!("- **Candidate findings**: {}\n", card.candidate_findings_total));
409 out.push_str(&format!(
410 "- **Business-logic templates**: {} considered, {} generated candidate(s), {} skipped\n\n",
411 card.business_logic_templates_considered,
412 card.business_logic_candidates_generated,
413 card.business_logic_templates_skipped,
414 ));
415
416 push_markdown_split(&mut out, "Status", &card.by_status);
417 push_markdown_split(&mut out, "Capability", &card.by_cap);
418 push_markdown_split(&mut out, "Origin", &card.by_origin);
419 push_markdown_split(&mut out, "Language", &card.by_lang);
420 push_markdown_split(&mut out, "Repository", &card.by_repo);
421 push_markdown_split(&mut out, "Candidate status", &card.by_candidate_status);
422
423 out.push_str("## AI spend\n\n");
424 out.push_str(&format!(
425 "- **One-shot**: ${:.6}\n- **Agent loop**: ${:.6}\n- **Total**: ${:.6}\n\n",
426 usd_from_micros(card.spend.one_shot_usd_micros),
427 usd_from_micros(card.spend.agent_loop_usd_micros),
428 usd_from_micros(card.spend.total_usd_micros()),
429 ));
430 for split in &card.spend.by_task_kind {
431 out.push_str(&format!(
432 "- {}: ${:.6}\n",
433 markdown_code(&split.key),
434 usd_from_micros(split.count)
435 ));
436 }
437 out.push('\n');
438
439 out.push_str("## Phase wall clock\n\n");
440 for phase in &card.phase_durations {
441 out.push_str(&format!(
442 "- {}: {} ms ({} call{})\n",
443 markdown_code(&phase.phase),
444 phase.wall_clock_ms,
445 phase.call_count,
446 if phase.call_count == 1 { "" } else { "s" },
447 ));
448 }
449 out
450}
451
452fn push_markdown_split(out: &mut String, title: &str, splits: &[BySplit]) {
453 out.push_str(&format!("## {title}\n\n"));
454 if splits.is_empty() {
455 out.push_str("_no rows_\n\n");
456 return;
457 }
458 for s in splits {
459 out.push_str(&format!("- {}: {}\n", markdown_code(&s.key), s.count));
460 }
461 out.push('\n');
462}
463
464fn markdown_code(s: &str) -> String {
470 let mut longest_run = 0usize;
471 let mut current_run = 0usize;
472 for ch in s.chars() {
473 if ch == '`' {
474 current_run += 1;
475 longest_run = longest_run.max(current_run);
476 } else {
477 current_run = 0;
478 }
479 }
480 let fence_len = longest_run + 1;
481 let fence: String = "`".repeat(fence_len);
482 let needs_pad = s.starts_with('`') || s.ends_with('`');
483 if needs_pad {
484 format!("{fence} {s} {fence}")
485 } else {
486 format!("{fence}{s}{fence}")
487 }
488}
489
490fn usd_from_micros(micros: i64) -> f64 {
491 micros as f64 / 1_000_000.0
492}
493
494#[derive(sqlx::FromRow)]
495struct RunRow {
496 id: String,
497 started_at: i64,
498 finished_at: Option<i64>,
499 status: String,
500 triggered_by: String,
501 wall_clock_ms: Option<i64>,
502}
503
504#[derive(sqlx::FromRow)]
505struct FindingRow {
506 id: String,
507 repo: String,
508 path: String,
509 cap: String,
510 status: String,
511 finding_origin: String,
512}
513
514#[derive(sqlx::FromRow)]
515struct PayloadRow {
516 finding_id: String,
517 lang: String,
518}
519
520#[derive(sqlx::FromRow)]
521struct TraceRow {
522 task_kind: String,
523 cost_usd_micros: i64,
524 started_at: i64,
525 finished_at: Option<i64>,
526}
527
528#[derive(sqlx::FromRow)]
529struct CandidateRow {
530 status: String,
531 count: i64,
532}
533
534#[derive(sqlx::FromRow)]
535struct BusinessLogicSummaryRow {
536 templates_considered: i64,
537 candidates_generated: i64,
538 templates_skipped: i64,
539}
540
541#[cfg(test)]
542mod tests {
543 use super::*;
544 use crate::store::testutil::{
545 fresh_store, sample_candidate, sample_finding, sample_payload, sample_repo, sample_run,
546 };
547 use crate::store::AgentTraceRecord;
548
549 async fn seed_two_repo_run(s: &crate::store::Store) -> String {
550 s.repos().upsert(&sample_repo("alpha")).await.expect("alpha");
551 s.repos().upsert(&sample_repo("beta")).await.expect("beta");
552 let mut run = sample_run("run-card-1");
553 run.status = "Succeeded".to_string();
554 run.finished_at = Some(12_000);
555 run.wall_clock_ms = Some(10_000);
556 s.runs().insert(&run).await.expect("run");
557 let f1 = sample_finding("run-card-1", "alpha", "src/a.py", "rule-a");
559 let f2 = sample_finding("run-card-1", "beta", "src/b.py", "rule-b");
560 let mut f3 = sample_finding("run-card-1", "alpha", "src/c.rs", "rule-c");
561 f3.cap = "cmdi".to_string();
562 f3.finding_origin = "AI".to_string();
563 f3.status = "Verified".to_string();
564 s.findings().upsert(&f1).await.expect("f1");
565 s.findings().upsert(&f2).await.expect("f2");
566 s.findings().upsert(&f3).await.expect("f3");
567 let mut p1 = sample_payload("p-1", &f1.id);
569 p1.lang = "python".to_string();
570 s.payloads().insert(&p1).await.expect("payload");
571 s.agent_traces()
573 .insert(&AgentTraceRecord {
574 id: "trace-1".to_string(),
575 finding_id: Some(f1.id.clone()),
576 task_kind: TaskKind::PayloadSynthesis.as_str().to_string(),
577 runtime_name: "anthropic".to_string(),
578 model: "claude-opus-4-7".to_string(),
579 prompt_version: Some("v1".to_string()),
580 conversation_jsonl_path: None,
581 tokens_in: 1_000,
582 tokens_out: 200,
583 cost_usd_micros: 123_456,
584 cache_hits: 0,
585 cache_misses: 1,
586 duration_ms: Some(2_000),
587 started_at: 4_000,
588 finished_at: Some(6_000),
589 verifier_blob: None,
590 })
591 .await
592 .expect("trace-1");
593 s.agent_traces()
595 .insert(&AgentTraceRecord {
596 id: "trace-2".to_string(),
597 finding_id: Some(f3.id.clone()),
598 task_kind: TaskKind::Exploration.as_str().to_string(),
599 runtime_name: "claude-code".to_string(),
600 model: "sonnet".to_string(),
601 prompt_version: Some("v1".to_string()),
602 conversation_jsonl_path: None,
603 tokens_in: 5_000,
604 tokens_out: 800,
605 cost_usd_micros: 500_000,
606 cache_hits: 0,
607 cache_misses: 1,
608 duration_ms: Some(7_000),
609 started_at: 5_000,
610 finished_at: Some(12_000),
611 verifier_blob: None,
612 })
613 .await
614 .expect("trace-2");
615 s.candidate_findings()
619 .insert(&sample_candidate("cand-pending", "run-card-1", "alpha"))
620 .await
621 .expect("cand-pending");
622 let mut promoted = sample_candidate("cand-promoted", "run-card-1", "beta");
623 promoted.status = "Promoted".to_string();
624 s.candidate_findings().insert(&promoted).await.expect("cand-promoted");
625 "run-card-1".to_string()
626 }
627
628 #[tokio::test]
629 async fn build_run_card_aggregates_counts_and_spend() {
630 let (_tmp, s) = fresh_store().await;
631 let run_id = seed_two_repo_run(&s).await;
632 let card = build_run_card(s.pool(), &run_id).await.expect("card");
633 assert_eq!(card.run_id, run_id);
634 assert_eq!(card.status, "Succeeded");
635 assert_eq!(card.total_findings, 3);
636
637 let by_cap: Vec<_> = card.by_cap.iter().map(|s| (s.key.as_str(), s.count)).collect();
638 assert!(by_cap.contains(&("sqli", 2)));
639 assert!(by_cap.contains(&("cmdi", 1)));
640
641 let by_origin: Vec<_> = card.by_origin.iter().map(|s| (s.key.as_str(), s.count)).collect();
642 assert!(by_origin.contains(&("Static", 2)));
643 assert!(by_origin.contains(&("AI", 1)));
644
645 let by_lang: Vec<_> = card.by_lang.iter().map(|s| (s.key.as_str(), s.count)).collect();
646 assert!(by_lang.contains(&("python", 2)), "expected python: {by_lang:?}");
647 assert!(by_lang.contains(&("rust", 1)));
648
649 let by_repo: Vec<_> = card.by_repo.iter().map(|s| (s.key.as_str(), s.count)).collect();
650 assert!(by_repo.contains(&("alpha", 2)));
651 assert!(by_repo.contains(&("beta", 1)));
652
653 assert_eq!(card.spend.one_shot_usd_micros, 123_456);
654 assert_eq!(card.spend.agent_loop_usd_micros, 500_000);
655 assert_eq!(card.spend.total_usd_micros(), 623_456);
656
657 let static_phase =
658 card.phase_durations.iter().find(|p| p.phase == "static").expect("static phase");
659 assert_eq!(static_phase.wall_clock_ms, 10_000);
660
661 let exploration = card
662 .phase_durations
663 .iter()
664 .find(|p| p.phase == TaskKind::Exploration.as_str())
665 .expect("exploration phase");
666 assert_eq!(exploration.wall_clock_ms, 7_000);
667 assert_eq!(exploration.call_count, 1);
668
669 assert_eq!(card.candidate_findings_total, 2);
673 let by_cand: Vec<_> =
674 card.by_candidate_status.iter().map(|s| (s.key.as_str(), s.count)).collect();
675 assert!(
676 by_cand.contains(&("Pending", 1)),
677 "by_candidate_status missing Pending: {by_cand:?}"
678 );
679 assert!(
680 by_cand.contains(&("Promoted", 1)),
681 "by_candidate_status missing Promoted: {by_cand:?}"
682 );
683 }
684
685 #[tokio::test]
686 async fn build_run_card_with_no_candidates_reports_zero_total() {
687 let (_tmp, s) = fresh_store().await;
688 s.repos().upsert(&sample_repo("alpha")).await.expect("alpha");
689 let mut run = sample_run("run-card-empty");
690 run.status = "Succeeded".to_string();
691 run.finished_at = Some(1);
692 run.wall_clock_ms = Some(1);
693 s.runs().insert(&run).await.expect("run");
694 let card = build_run_card(s.pool(), "run-card-empty").await.expect("card");
695 assert_eq!(card.candidate_findings_total, 0);
696 assert!(
697 card.by_candidate_status.is_empty(),
698 "no candidate rows should yield no per-status split",
699 );
700 }
701
702 #[tokio::test]
703 async fn build_run_card_missing_run_returns_not_found() {
704 let (_tmp, s) = fresh_store().await;
705 let err = build_run_card(s.pool(), "nope").await.expect_err("not found");
706 assert!(matches!(err, RunCardError::NotFound(_)));
707 }
708
709 #[tokio::test]
710 async fn render_markdown_round_trips_card() {
711 let (_tmp, s) = fresh_store().await;
712 let run_id = seed_two_repo_run(&s).await;
713 let card = build_run_card(s.pool(), &run_id).await.expect("card");
714 let md = render_markdown(&card);
715 assert!(md.contains("Run `run-card-1`"));
716 assert!(md.contains("Total findings"));
717 assert!(md.contains("One-shot"));
718 assert!(md.contains("Agent loop"));
719 assert!(md.contains("Phase wall clock"));
720 }
721
722 #[test]
723 fn markdown_code_wraps_simple_input() {
724 assert_eq!(markdown_code("sqli"), "`sqli`");
725 }
726
727 #[test]
728 fn markdown_code_lengthens_fence_to_dodge_inner_backticks() {
729 assert_eq!(markdown_code("a`b"), "``a`b``");
731 }
732
733 #[test]
734 fn markdown_code_pads_when_content_borders_with_backtick() {
735 assert_eq!(markdown_code("`foo"), "`` `foo ``");
736 assert_eq!(markdown_code("foo`"), "`` foo` ``");
737 }
738
739 #[test]
740 fn render_markdown_neutralises_injected_html_in_by_split_keys() {
741 let card = RunCard {
744 run_id: "run-1".to_string(),
745 started_at: 0,
746 finished_at: Some(1),
747 status: "Succeeded".to_string(),
748 triggered_by: "UI".to_string(),
749 wall_clock_ms: Some(1),
750 total_findings: 1,
751 by_status: vec![BySplit { key: "Open".to_string(), count: 1 }],
752 by_cap: vec![BySplit { key: "<img src=x onerror=alert(1)>".to_string(), count: 1 }],
753 by_origin: Vec::new(),
754 by_lang: Vec::new(),
755 by_repo: Vec::new(),
756 spend: SpendSplit::default(),
757 phase_durations: Vec::new(),
758 candidate_findings_total: 0,
759 by_candidate_status: Vec::new(),
760 business_logic_templates_considered: 0,
761 business_logic_candidates_generated: 0,
762 business_logic_templates_skipped: 0,
763 };
764 let md = render_markdown(&card);
765 assert!(md.contains("`<img src=x onerror=alert(1)>`"));
769 assert!(!md.split('`').enumerate().any(|(i, seg)| i % 2 == 0 && seg.contains("<img")));
771 }
772
773 #[tokio::test]
774 async fn render_html_round_trips_card() {
775 let (_tmp, s) = fresh_store().await;
776 let run_id = seed_two_repo_run(&s).await;
777 let card = build_run_card(s.pool(), &run_id).await.expect("card");
778 let html = render_html(&card);
779 assert!(html.contains("<h2>Run run-card-1</h2>"));
780 assert!(html.contains("<h3>Capability</h3>"));
781 assert!(!html.contains("<script>"));
784 }
785
786 #[test]
787 fn lang_from_path_handles_common_extensions() {
788 assert_eq!(lang_from_path("src/foo.rs"), "rust");
789 assert_eq!(lang_from_path("src/foo.PY"), "python");
790 assert_eq!(lang_from_path("src/foo.tsx"), "typescript");
791 assert_eq!(lang_from_path("Dockerfile"), "unknown");
792 }
793}