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 pub newer_fields: Vec<String>,
162 pub verification: Option<ObjectVerification>,
164}
165
166impl TickReport {
167 #[must_use]
169 pub fn fails(&self, require_verified: bool) -> bool {
170 match self.outcome {
171 TickOutcome::Evidenced => {
172 require_verified && self.verdict.as_deref() != Some("verified")
173 }
174 TickOutcome::Stale | TickOutcome::Unverifiable | TickOutcome::Regressed => true,
175 }
176 }
177}
178
179#[derive(Debug, Clone, PartialEq, Eq, Serialize, schemars::JsonSchema)]
181pub struct SkippedTask {
182 pub feature: String,
184 pub task: String,
186}
187
188#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, schemars::JsonSchema)]
190#[serde(rename_all = "snake_case")]
191pub enum Mode {
192 Touched,
194 All,
196}
197
198#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, schemars::JsonSchema)]
200#[serde(rename_all = "snake_case")]
201pub enum Conclusion {
202 Success,
204 Failure,
207 Neutral,
209}
210
211impl Conclusion {
212 #[must_use]
214 pub const fn word(self) -> &'static str {
215 match self {
216 Self::Success => "success",
217 Self::Failure => "failure",
218 Self::Neutral => "neutral",
219 }
220 }
221
222 #[must_use]
224 pub const fn exit_code(self) -> u8 {
225 match self {
226 Self::Success => 0,
227 Self::Failure => 1,
228 Self::Neutral => 78,
229 }
230 }
231
232 #[must_use]
235 pub fn of(ticks: &[TickReport], require_verified: bool, not_evaluated: &[String]) -> Self {
236 if ticks.iter().any(|t| t.fails(require_verified))
237 || not_evaluated.iter().any(|n| n == TRAILER_ONLY_TASKS)
238 {
239 Self::Failure
240 } else if ticks.is_empty() {
241 Self::Neutral
242 } else {
243 Self::Success
244 }
245 }
246}
247
248#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, schemars::JsonSchema)]
250pub struct BranchTotals {
251 pub verified: usize,
253 pub regression_only: usize,
255 pub attested: usize,
257 pub stale: usize,
259 pub unverifiable: usize,
261 pub regressed: usize,
263 pub skipped: usize,
265}
266
267impl BranchTotals {
268 #[must_use]
270 pub fn of(ticks: &[TickReport], skipped: usize) -> Self {
271 let mut t = Self {
272 skipped,
273 ..Self::default()
274 };
275 for r in ticks {
276 match (r.outcome, r.verdict.as_deref()) {
277 (TickOutcome::Evidenced, Some("verified")) => t.verified += 1,
278 (TickOutcome::Evidenced, Some("attested")) => t.attested += 1,
279 (TickOutcome::Evidenced, _) => t.regression_only += 1,
280 (TickOutcome::Stale, _) => t.stale += 1,
281 (TickOutcome::Unverifiable, _) => t.unverifiable += 1,
282 (TickOutcome::Regressed, _) => t.regressed += 1,
283 }
284 }
285 t
286 }
287}
288
289#[derive(Debug, Clone, PartialEq, Serialize, schemars::JsonSchema)]
291pub struct BranchReport {
292 pub head: String,
294 pub base: Option<String>,
296 pub mode: Mode,
298 pub features: Vec<String>,
300 pub ticks: Vec<TickReport>,
302 pub skipped: Vec<SkippedTask>,
304 pub totals: BranchTotals,
306 pub conclusion: Conclusion,
308 pub trust: serde_json::Value,
311 pub accepted_unsigned: bool,
313 pub require_verified: bool,
315 pub trace: Vec<TraceRow>,
318 pub share: Option<f64>,
320 pub open_no_claim: Vec<String>,
322 pub not_evaluated: Vec<String>,
324 pub newer_fields: Vec<String>,
326}
327
328impl BranchReport {
329 #[must_use]
332 pub fn title(&self) -> String {
333 if self.conclusion == Conclusion::Neutral {
334 return "neutral: no ticks checked".to_owned();
335 }
336 let t = &self.totals;
337 let mut title = format!(
338 "{}: {} verified, {} regression only, {} attested, {} stale, {} unverifiable",
339 self.conclusion.word(),
340 t.verified,
341 t.regression_only,
342 t.attested,
343 t.stale,
344 t.unverifiable
345 );
346 if t.regressed > 0 {
347 title.push_str(&format!(", {} regressed", t.regressed));
348 }
349 title
350 }
351}
352
353#[must_use]
355pub fn share(ticks: &[TickReport]) -> Option<f64> {
356 if ticks.is_empty() {
357 return None;
358 }
359 let evidenced = ticks
360 .iter()
361 .filter(|t| t.outcome == TickOutcome::Evidenced)
362 .count();
363 #[allow(clippy::cast_precision_loss)] Some(evidenced as f64 / ticks.len() as f64)
365}
366
367#[derive(Debug, Clone, PartialEq, Eq, Serialize, schemars::JsonSchema)]
369pub struct Badge {
370 #[serde(rename = "schemaVersion")]
372 pub schema_version: u32,
373 pub label: String,
375 pub message: String,
377 pub color: String,
379}
380
381impl Badge {
382 #[must_use]
385 pub fn of(report: &BranchReport) -> Self {
386 let (message, color) = match report.share {
387 None => ("none".to_owned(), "lightgrey"),
388 Some(s) => {
389 let percent = (s * 100.0).floor();
390 let color = if percent >= 95.0 && report.totals.unverifiable == 0 {
391 "brightgreen"
392 } else if percent >= 75.0 {
393 "yellow"
394 } else {
395 "red"
396 };
397 (format!("{percent:.0}%"), color)
398 }
399 };
400 Self {
401 schema_version: 1,
402 label: "evidence".to_owned(),
403 message,
404 color: color.to_owned(),
405 }
406 }
407
408 #[must_use]
410 pub fn svg(&self) -> String {
411 let fill = match self.color.as_str() {
412 "brightgreen" => "#4c1",
413 "yellow" => "#dfb317",
414 "red" => "#e05d44",
415 _ => "#9f9f9f",
416 };
417 let width = |s: &str| u32::try_from(s.chars().count()).unwrap_or(8) * 7 + 10;
419 let (lw, mw) = (width(&self.label), width(&self.message));
420 let total = lw + mw;
421 format!(
422 "<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"{total}\" height=\"20\" role=\"img\" aria-label=\"{label}: {message}\">\
423<title>{label}: {message}</title>\
424<rect width=\"{lw}\" height=\"20\" fill=\"#555\"/>\
425<rect x=\"{lw}\" width=\"{mw}\" height=\"20\" fill=\"{fill}\"/>\
426<g fill=\"#fff\" text-anchor=\"middle\" font-family=\"Verdana,Geneva,DejaVu Sans,sans-serif\" font-size=\"11\">\
427<text x=\"{lx}\" y=\"14\">{label}</text><text x=\"{mx}\" y=\"14\">{message}</text></g></svg>\n",
428 label = self.label,
429 message = self.message,
430 lx = lw / 2,
431 mx = lw + mw / 2,
432 )
433 }
434}
435
436#[cfg(test)]
437mod tests {
438 use super::*;
439
440 fn tick(task: &str, outcome: TickOutcome, verdict: Option<&str>) -> TickReport {
441 TickReport {
442 tick: Tick {
443 feature: "001-x".to_owned(),
444 task: task.to_owned(),
445 title: String::new(),
446 kind: TickKind::Tick,
447 anchor: Anchor {
448 path: "specs/001-x/tasks.md".to_owned(),
449 line: 1,
450 },
451 line_sha256: None,
452 claim_sha256: None,
453 },
454 commit: None,
455 outcome,
456 verdict: verdict.map(str::to_owned),
457 reason: (outcome == TickOutcome::Unverifiable)
458 .then_some(Unverifiable::TickedWithoutEvidence),
459 detail: None,
460 signer: None,
461 decider: None,
462 stale: Vec::new(),
463 stale_anchor: None,
464 newer_fields: Vec::new(),
465 verification: None,
466 }
467 }
468
469 fn report(ticks: Vec<TickReport>) -> BranchReport {
470 let conclusion = Conclusion::of(&ticks, false, &[]);
471 BranchReport {
472 head: "h".to_owned(),
473 base: None,
474 mode: Mode::All,
475 features: vec!["001-x".to_owned()],
476 totals: BranchTotals::of(&ticks, 0),
477 share: share(&ticks),
478 ticks,
479 skipped: Vec::new(),
480 conclusion,
481 trust: serde_json::Value::Null,
482 accepted_unsigned: false,
483 require_verified: false,
484 trace: Vec::new(),
485 open_no_claim: Vec::new(),
486 not_evaluated: Vec::new(),
487 newer_fields: Vec::new(),
488 }
489 }
490
491 #[test]
492 fn the_conclusion_is_failure_on_any_failing_tick_and_never_success_without_ticks() {
493 use TickOutcome::*;
494 let ok = tick("T1", Evidenced, Some("verified"));
495 let ro = tick("T2", Evidenced, Some("regression_only"));
496 assert_eq!(Conclusion::of(&[], false, &[]), Conclusion::Neutral);
497 assert_eq!(
498 Conclusion::of(&[ok.clone(), ro.clone()], false, &[]),
499 Conclusion::Success
500 );
501 assert_eq!(
502 Conclusion::of(&[ok.clone(), ro], true, &[]),
503 Conclusion::Failure
504 );
505 for bad in [Stale, Unverifiable, Regressed] {
506 assert_eq!(
507 Conclusion::of(&[ok.clone(), tick("T3", bad, None)], false, &[]),
508 Conclusion::Failure,
509 "{bad:?}"
510 );
511 }
512 assert_eq!(
514 Conclusion::of(&[], false, &[TRAILER_ONLY_TASKS.to_owned()]),
515 Conclusion::Failure
516 );
517 assert_eq!(
518 Conclusion::of(&[ok], false, &[EARLIER_TASK_REGRESSIONS.to_owned()]),
519 Conclusion::Success
520 );
521 }
522
523 #[test]
524 fn the_title_counts_in_the_bars_order() {
525 use TickOutcome::*;
526 let r = report(vec![
527 tick("T1", Evidenced, Some("verified")),
528 tick("T2", Evidenced, Some("attested")),
529 tick("T3", Unverifiable, None),
530 ]);
531 assert_eq!(
532 r.title(),
533 "failure: 1 verified, 0 regression only, 1 attested, 0 stale, 1 unverifiable"
534 );
535 assert_eq!(report(Vec::new()).title(), "neutral: no ticks checked");
536 }
537
538 #[test]
539 fn the_badge_reads_eight_of_ten_as_eighty_percent_and_never_green_with_an_unverifiable_tick() {
540 use TickOutcome::*;
541 let mut ticks: Vec<TickReport> = (0..8)
542 .map(|i| tick(&format!("T{i}"), Evidenced, Some("verified")))
543 .collect();
544 ticks.push(tick("T8", Stale, Some("verified")));
545 ticks.push(tick("T9", Unverifiable, None));
546 let b = Badge::of(&report(ticks));
547 assert_eq!((b.message.as_str(), b.color.as_str()), ("80%", "yellow"));
548 assert!(b.svg().contains(">80%<"));
549 let mut all: Vec<TickReport> = (0..40)
550 .map(|i| tick(&format!("T{i}"), Evidenced, Some("verified")))
551 .collect();
552 all.push(tick("T99", Unverifiable, None));
553 let b = Badge::of(&report(all));
554 assert_eq!((b.message.as_str(), b.color.as_str()), ("97%", "yellow"));
555 assert_eq!(Badge::of(&report(Vec::new())).message, "none");
556 }
557}