1use serde::Serialize;
10
11use crate::{ObjectVerification, RequirementDrift, SignerView, TraceRow, VerifyOutcome};
12
13pub const EARLIER_TASK_REGRESSIONS: &str = "earlier_task_regressions";
16pub const TRAILER_ONLY_TASKS: &str = "trailer_only_tasks";
19
20#[derive(Debug, Clone, PartialEq, Eq, Serialize, schemars::JsonSchema)]
22pub struct Anchor {
23 pub path: String,
25 pub line: u32,
27}
28
29#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, schemars::JsonSchema)]
31#[serde(rename_all = "snake_case")]
32pub enum TickKind {
33 Tick,
35 TrailerOnly,
38}
39
40#[derive(Debug, Clone, PartialEq, Eq, Serialize, schemars::JsonSchema)]
42pub struct Tick {
43 pub feature: String,
45 pub task: String,
47 pub title: String,
49 pub kind: TickKind,
51 pub anchor: Anchor,
53 pub line_sha256: Option<String>,
56 pub claim_sha256: Option<String>,
58}
59
60#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, schemars::JsonSchema)]
62#[serde(rename_all = "snake_case")]
63pub enum Unverifiable {
64 TickedWithoutEvidence,
66 Missing,
68 Malformed,
70 BadSignature,
72 Mismatch,
75 UntrustedSigner,
77 Unsigned,
79 CommitUnreachable,
82 ToolMissing,
84}
85
86impl Unverifiable {
87 #[must_use]
89 pub const fn of(outcome: VerifyOutcome) -> Option<Self> {
90 match outcome {
91 VerifyOutcome::Ok => None,
92 VerifyOutcome::Unsigned => Some(Self::Unsigned),
93 VerifyOutcome::Missing => Some(Self::Missing),
94 VerifyOutcome::BadSignature => Some(Self::BadSignature),
95 VerifyOutcome::UntrustedSigner => Some(Self::UntrustedSigner),
96 VerifyOutcome::Mismatch => Some(Self::Mismatch),
97 VerifyOutcome::Malformed => Some(Self::Malformed),
98 VerifyOutcome::ToolMissing => Some(Self::ToolMissing),
99 }
100 }
101
102 #[must_use]
104 pub const fn word(self) -> &'static str {
105 match self {
106 Self::TickedWithoutEvidence => "ticked_without_evidence",
107 Self::Missing => "missing",
108 Self::Malformed => "malformed",
109 Self::BadSignature => "bad_signature",
110 Self::Mismatch => "mismatch",
111 Self::UntrustedSigner => "untrusted_signer",
112 Self::Unsigned => "unsigned",
113 Self::CommitUnreachable => "commit_unreachable",
114 Self::ToolMissing => "tool_missing",
115 }
116 }
117}
118
119#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, schemars::JsonSchema)]
121#[serde(rename_all = "snake_case")]
122pub enum TickOutcome {
123 Evidenced,
125 Stale,
128 Unverifiable,
130 Regressed,
133}
134
135#[derive(Debug, Clone, PartialEq, Eq, Serialize, schemars::JsonSchema)]
137pub struct TickReport {
138 pub tick: Tick,
140 pub commit: Option<String>,
143 pub outcome: TickOutcome,
145 pub verdict: Option<String>,
147 pub reason: Option<Unverifiable>,
149 pub detail: Option<String>,
151 pub signer: Option<SignerView>,
153 pub decider: Option<String>,
155 pub stale: Vec<RequirementDrift>,
157 pub stale_anchor: Option<Anchor>,
159 #[serde(default, skip_serializing_if = "Option::is_none")]
161 pub regression: Option<String>,
162 pub newer_fields: Vec<String>,
165 pub verification: Option<ObjectVerification>,
167}
168
169impl TickReport {
170 #[must_use]
172 pub fn fails(&self, require_verified: bool) -> bool {
173 match self.outcome {
174 TickOutcome::Evidenced => {
175 require_verified && self.verdict.as_deref() != Some("verified")
176 }
177 TickOutcome::Stale | TickOutcome::Unverifiable | TickOutcome::Regressed => true,
178 }
179 }
180}
181
182#[derive(Debug, Clone, PartialEq, Eq, Serialize, schemars::JsonSchema)]
184pub struct SkippedTask {
185 pub feature: String,
187 pub task: String,
189}
190
191#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, schemars::JsonSchema)]
193#[serde(rename_all = "snake_case")]
194pub enum Mode {
195 Touched,
197 All,
199}
200
201#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, schemars::JsonSchema)]
203#[serde(rename_all = "snake_case")]
204pub enum Conclusion {
205 Success,
207 Failure,
210 Neutral,
212}
213
214impl Conclusion {
215 #[must_use]
217 pub const fn word(self) -> &'static str {
218 match self {
219 Self::Success => "success",
220 Self::Failure => "failure",
221 Self::Neutral => "neutral",
222 }
223 }
224
225 #[must_use]
228 pub fn of(ticks: &[TickReport], require_verified: bool, not_evaluated: &[String]) -> Self {
229 if ticks.iter().any(|t| t.fails(require_verified))
230 || not_evaluated.iter().any(|n| n == TRAILER_ONLY_TASKS)
231 {
232 Self::Failure
233 } else if ticks.is_empty() {
234 Self::Neutral
235 } else {
236 Self::Success
237 }
238 }
239}
240
241#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, schemars::JsonSchema)]
243pub struct BranchTotals {
244 pub verified: usize,
246 pub regression_only: usize,
248 pub attested: usize,
250 pub stale: usize,
252 pub unverifiable: usize,
254 pub regressed: usize,
256 pub skipped: usize,
258}
259
260impl BranchTotals {
261 #[must_use]
263 pub fn of(ticks: &[TickReport], skipped: usize) -> Self {
264 let mut t = Self {
265 skipped,
266 ..Self::default()
267 };
268 for r in ticks {
269 match (r.outcome, r.verdict.as_deref()) {
270 (TickOutcome::Evidenced, Some("verified")) => t.verified += 1,
271 (TickOutcome::Evidenced, Some("attested")) => t.attested += 1,
272 (TickOutcome::Evidenced, _) => t.regression_only += 1,
273 (TickOutcome::Stale, _) => t.stale += 1,
274 (TickOutcome::Unverifiable, _) => t.unverifiable += 1,
275 (TickOutcome::Regressed, _) => t.regressed += 1,
276 }
277 }
278 t
279 }
280}
281
282#[derive(Debug, Clone, PartialEq, Serialize, schemars::JsonSchema)]
284pub struct BranchReport {
285 pub head: String,
287 pub base: Option<String>,
289 pub mode: Mode,
291 pub features: Vec<String>,
293 pub ticks: Vec<TickReport>,
295 pub skipped: Vec<SkippedTask>,
297 pub totals: BranchTotals,
299 pub conclusion: Conclusion,
301 pub trust: serde_json::Value,
304 pub accepted_unsigned: bool,
306 pub require_verified: bool,
308 pub trace: Vec<TraceRow>,
311 pub share: Option<f64>,
313 pub open_no_claim: Vec<String>,
315 pub not_evaluated: Vec<String>,
317 pub newer_fields: Vec<String>,
319}
320
321impl BranchReport {
322 #[must_use]
325 pub fn title(&self) -> String {
326 if self.conclusion == Conclusion::Neutral {
327 return "neutral: no ticks checked".to_owned();
328 }
329 let t = &self.totals;
330 let mut title = format!(
331 "{}: {} verified, {} regression only, {} attested, {} stale, {} unverifiable",
332 self.conclusion.word(),
333 t.verified,
334 t.regression_only,
335 t.attested,
336 t.stale,
337 t.unverifiable
338 );
339 if t.regressed > 0 {
340 title.push_str(&format!(", {} regressed", t.regressed));
341 }
342 title
343 }
344}
345
346#[must_use]
348pub fn share(ticks: &[TickReport]) -> Option<f64> {
349 if ticks.is_empty() {
350 return None;
351 }
352 let evidenced = ticks
353 .iter()
354 .filter(|t| t.outcome == TickOutcome::Evidenced)
355 .count();
356 #[allow(clippy::cast_precision_loss)] Some(evidenced as f64 / ticks.len() as f64)
358}
359
360#[derive(Debug, Clone, PartialEq, Eq, Serialize, schemars::JsonSchema)]
362pub struct Badge {
363 #[serde(rename = "schemaVersion")]
365 pub schema_version: u32,
366 pub label: String,
368 pub message: String,
370 pub color: String,
372}
373
374impl Badge {
375 #[must_use]
378 pub fn of(report: &BranchReport) -> Self {
379 let (message, color) = match report.share {
380 None => ("none".to_owned(), "lightgrey"),
381 Some(s) => {
382 let percent = (s * 100.0).floor();
383 let color = if percent >= 95.0 && report.totals.unverifiable == 0 {
384 "brightgreen"
385 } else if percent >= 75.0 {
386 "yellow"
387 } else {
388 "red"
389 };
390 (format!("{percent:.0}%"), color)
391 }
392 };
393 Self {
394 schema_version: 1,
395 label: "evidence".to_owned(),
396 message,
397 color: color.to_owned(),
398 }
399 }
400
401 #[must_use]
403 pub fn svg(&self) -> String {
404 let fill = match self.color.as_str() {
405 "brightgreen" => "#4c1",
406 "yellow" => "#dfb317",
407 "red" => "#e05d44",
408 _ => "#9f9f9f",
409 };
410 let width = |s: &str| u32::try_from(s.chars().count()).unwrap_or(8) * 7 + 10;
412 let (lw, mw) = (width(&self.label), width(&self.message));
413 let total = lw + mw;
414 format!(
415 "<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"{total}\" height=\"20\" role=\"img\" aria-label=\"{label}: {message}\">\
416<title>{label}: {message}</title>\
417<rect width=\"{lw}\" height=\"20\" fill=\"#555\"/>\
418<rect x=\"{lw}\" width=\"{mw}\" height=\"20\" fill=\"{fill}\"/>\
419<g fill=\"#fff\" text-anchor=\"middle\" font-family=\"Verdana,Geneva,DejaVu Sans,sans-serif\" font-size=\"11\">\
420<text x=\"{lx}\" y=\"14\">{label}</text><text x=\"{mx}\" y=\"14\">{message}</text></g></svg>\n",
421 label = self.label,
422 message = self.message,
423 lx = lw / 2,
424 mx = lw + mw / 2,
425 )
426 }
427}
428
429#[cfg(test)]
430mod tests {
431 use super::*;
432
433 fn tick(task: &str, outcome: TickOutcome, verdict: Option<&str>) -> TickReport {
434 TickReport {
435 tick: Tick {
436 feature: "001-x".to_owned(),
437 task: task.to_owned(),
438 title: String::new(),
439 kind: TickKind::Tick,
440 anchor: Anchor {
441 path: "specs/001-x/tasks.md".to_owned(),
442 line: 1,
443 },
444 line_sha256: None,
445 claim_sha256: None,
446 },
447 commit: None,
448 outcome,
449 verdict: verdict.map(str::to_owned),
450 reason: (outcome == TickOutcome::Unverifiable)
451 .then_some(Unverifiable::TickedWithoutEvidence),
452 detail: None,
453 signer: None,
454 decider: None,
455 stale: Vec::new(),
456 stale_anchor: None,
457 regression: None,
458 newer_fields: Vec::new(),
459 verification: None,
460 }
461 }
462
463 fn report(ticks: Vec<TickReport>) -> BranchReport {
464 let conclusion = Conclusion::of(&ticks, false, &[]);
465 BranchReport {
466 head: "h".to_owned(),
467 base: None,
468 mode: Mode::All,
469 features: vec!["001-x".to_owned()],
470 totals: BranchTotals::of(&ticks, 0),
471 share: share(&ticks),
472 ticks,
473 skipped: Vec::new(),
474 conclusion,
475 trust: serde_json::Value::Null,
476 accepted_unsigned: false,
477 require_verified: false,
478 trace: Vec::new(),
479 open_no_claim: Vec::new(),
480 not_evaluated: Vec::new(),
481 newer_fields: Vec::new(),
482 }
483 }
484
485 #[test]
486 fn the_conclusion_is_failure_on_any_failing_tick_and_never_success_without_ticks() {
487 use TickOutcome::*;
488 let ok = tick("T1", Evidenced, Some("verified"));
489 let ro = tick("T2", Evidenced, Some("regression_only"));
490 assert_eq!(Conclusion::of(&[], false, &[]), Conclusion::Neutral);
491 assert_eq!(
492 Conclusion::of(&[ok.clone(), ro.clone()], false, &[]),
493 Conclusion::Success
494 );
495 assert_eq!(
496 Conclusion::of(&[ok.clone(), ro], true, &[]),
497 Conclusion::Failure
498 );
499 for bad in [Stale, Unverifiable, Regressed] {
500 assert_eq!(
501 Conclusion::of(&[ok.clone(), tick("T3", bad, None)], false, &[]),
502 Conclusion::Failure,
503 "{bad:?}"
504 );
505 }
506 assert_eq!(
508 Conclusion::of(&[], false, &[TRAILER_ONLY_TASKS.to_owned()]),
509 Conclusion::Failure
510 );
511 assert_eq!(
512 Conclusion::of(&[ok], false, &[EARLIER_TASK_REGRESSIONS.to_owned()]),
513 Conclusion::Success
514 );
515 }
516
517 #[test]
518 fn the_title_counts_in_the_bars_order() {
519 use TickOutcome::*;
520 let r = report(vec![
521 tick("T1", Evidenced, Some("verified")),
522 tick("T2", Evidenced, Some("attested")),
523 tick("T3", Unverifiable, None),
524 ]);
525 assert_eq!(
526 r.title(),
527 "failure: 1 verified, 0 regression only, 1 attested, 0 stale, 1 unverifiable"
528 );
529 assert_eq!(report(Vec::new()).title(), "neutral: no ticks checked");
530 }
531
532 #[test]
533 fn the_badge_reads_eight_of_ten_as_eighty_percent_and_never_green_with_an_unverifiable_tick() {
534 use TickOutcome::*;
535 let mut ticks: Vec<TickReport> = (0..8)
536 .map(|i| tick(&format!("T{i}"), Evidenced, Some("verified")))
537 .collect();
538 ticks.push(tick("T8", Stale, Some("verified")));
539 ticks.push(tick("T9", Unverifiable, None));
540 let b = Badge::of(&report(ticks));
541 assert_eq!((b.message.as_str(), b.color.as_str()), ("80%", "yellow"));
542 assert!(b.svg().contains(">80%<"));
543 let mut all: Vec<TickReport> = (0..40)
544 .map(|i| tick(&format!("T{i}"), Evidenced, Some("verified")))
545 .collect();
546 all.push(tick("T99", Unverifiable, None));
547 let b = Badge::of(&report(all));
548 assert_eq!((b.message.as_str(), b.color.as_str()), ("97%", "yellow"));
549 assert_eq!(Badge::of(&report(Vec::new())).message, "none");
550 }
551}