1use serde::{Deserialize, Serialize};
9
10use crate::actions::Via;
11
12#[derive(
14 Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema,
15)]
16#[serde(rename_all = "snake_case")]
17pub enum RunMode {
18 #[default]
21 Ask,
22 Autopilot,
25}
26
27impl RunMode {
28 #[must_use]
30 pub const fn as_str(self) -> &'static str {
31 match self {
32 Self::Ask => "ask",
33 Self::Autopilot => "autopilot",
34 }
35 }
36}
37
38#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
40#[serde(rename_all = "snake_case")]
41pub enum OracleOp {
42 Write,
45 Reject,
47 Again,
49 Edit,
51 Reading,
53}
54
55#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
57pub enum Reading {
58 A,
60 B,
62}
63
64impl Reading {
65 #[must_use]
67 pub const fn as_str(self) -> &'static str {
68 match self {
69 Self::A => "A",
70 Self::B => "B",
71 }
72 }
73}
74
75#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
77pub struct Expectation {
78 pub scenario: String,
80 pub given: String,
82 pub expect: String,
84}
85
86#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
88pub struct Untestable {
89 pub scenario: String,
91 pub why: String,
93}
94
95#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
97pub struct ReadingText {
98 pub given: String,
100 pub expect: String,
102}
103
104#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
106pub struct Resolution {
107 pub reading: Reading,
109 pub by: String,
111 pub at: u64,
113}
114
115#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
117pub struct Ambiguity {
118 pub scenario: String,
120 pub a: ReadingText,
122 pub b: ReadingText,
124 pub resolution: Option<Resolution>,
126}
127
128impl Ambiguity {
129 #[must_use]
131 pub const fn open(&self) -> bool {
132 self.resolution.is_none()
133 }
134}
135
136#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
138#[serde(rename_all = "snake_case")]
139pub enum AuthoredBy {
140 Model,
142 Person,
144}
145
146#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
148pub struct OracleTest {
149 pub path: String,
151 pub content: String,
153 pub scenarios: Vec<String>,
155 pub red_class: String,
158 pub red: bool,
160 pub authored_by: AuthoredBy,
162 pub edited_by: Option<String>,
164}
165
166#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
168pub struct OracleDiscarded {
169 pub path: String,
171 pub why: String,
173}
174
175#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
177pub struct OracleRejected {
178 pub path: String,
180 pub reason: String,
182 pub by: String,
184 pub at: u64,
186}
187
188#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
191pub struct HeldOutSummary {
192 pub state: String,
194 pub count: usize,
196 pub manifest_sha256: Option<String>,
198 pub reason: Option<String>,
200 #[serde(default, skip_serializing_if = "Vec::is_empty")]
203 pub discarded: Vec<String>,
204}
205
206#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
208#[serde(rename_all = "snake_case")]
209pub enum OracleState {
210 None,
212 Written,
214 NotRed,
216 Failed,
218 Ambiguous,
220 Frozen,
222 Lapsed,
224 FrozenElsewhere,
226}
227
228impl OracleState {
229 #[must_use]
231 pub const fn as_str(self) -> &'static str {
232 match self {
233 Self::None => "none",
234 Self::Written => "written",
235 Self::NotRed => "not_red",
236 Self::Failed => "failed",
237 Self::Ambiguous => "ambiguous",
238 Self::Frozen => "frozen",
239 Self::Lapsed => "lapsed",
240 Self::FrozenElsewhere => "frozen_elsewhere",
241 }
242 }
243}
244
245#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
247pub struct OracleView {
248 pub req: String,
250 pub title: String,
252 pub scenarios: Vec<crate::spec::ScenarioRef>,
254 pub state: OracleState,
256 pub frozen_on: Option<String>,
258 pub frozen_by: Option<String>,
260 pub frozen_at: Option<u64>,
262 pub tests: Vec<OracleTest>,
264 pub discarded: Vec<OracleDiscarded>,
266 pub rejected: Vec<OracleRejected>,
268 pub uncovered: Vec<String>,
270 pub untestable: Vec<Untestable>,
272 pub ambiguities: Vec<Ambiguity>,
274 pub held_out: HeldOutSummary,
276 pub strength_target: Option<u8>,
278 pub calls: u8,
280 pub ambiguity_check: bool,
282 pub manifest_sha256: Option<String>,
284 pub error: Option<String>,
286}
287
288impl OracleView {
289 pub fn open_ambiguities(&self) -> impl Iterator<Item = &Ambiguity> {
291 self.ambiguities.iter().filter(|a| a.open())
292 }
293
294 #[must_use]
296 pub fn freezes(&self) -> String {
297 let red = self.tests.iter().filter(|t| t.red).count();
298 if let Some(a) = self.open_ambiguities().next() {
299 return format!("blocked: two readings of {}", a.scenario);
300 }
301 match self.state {
302 OracleState::Frozen => {
303 format!("{red} {} frozen", if red == 1 { "test" } else { "tests" })
304 }
305 _ if red == 0 => "tests written at run time".to_owned(),
306 _ => {
307 let mut parts = vec![format!("{red} {}", if red == 1 { "test" } else { "tests" })];
308 if self.held_out.state == "kept" {
309 parts.push(format!("{} held out", self.held_out.count));
310 }
311 if let Some(t) = self.strength_target {
312 parts.push(format!("target {t}%"));
313 }
314 parts.join(" · ")
315 }
316 }
317 }
318}
319
320#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
322pub struct ReviewApproval {
323 pub sha256: String,
326 pub mode: RunMode,
328 pub by: String,
330 pub via: Via,
332 pub at: u64,
334}
335
336#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
339pub struct OfferedGrant {
340 pub id: String,
342 pub class: String,
344 pub object: String,
346}
347
348#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
351pub struct ApproveSheet {
352 #[serde(default)]
356 pub sha256: Option<String>,
357 pub approves: Vec<String>,
359 pub blocked: Vec<String>,
362 pub autopilot_blocked: Option<String>,
364 pub sandbox: Option<String>,
366 pub grants: Vec<OfferedGrant>,
368}
369
370#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
372pub struct AutopilotDenial {
373 pub task: String,
375 pub turn: u32,
377 pub class: String,
379 pub object: String,
381 pub reason: String,
383}
384
385#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
387#[serde(tag = "kind", rename_all = "snake_case")]
388pub enum CommentAnchor {
389 Passage {
391 path: String,
393 start_line: usize,
395 end_line: usize,
397 quote: String,
399 #[serde(default)]
401 quote_sha256: String,
402 #[serde(default)]
404 section: String,
405 },
406 DiffLine {
408 path: String,
410 side: String,
412 line: usize,
414 quote: String,
416 },
417 TestLine {
419 req: String,
421 path: String,
423 line: usize,
425 quote: String,
427 },
428 Finding {
430 source: String,
432 id: String,
434 text: String,
436 },
437}
438
439impl CommentAnchor {
440 #[must_use]
442 pub fn quote(&self) -> &str {
443 match self {
444 Self::Passage { quote, .. }
445 | Self::DiffLine { quote, .. }
446 | Self::TestLine { quote, .. } => quote,
447 Self::Finding { text, .. } => text,
448 }
449 }
450
451 #[must_use]
453 pub fn place(&self) -> String {
454 match self {
455 Self::Passage {
456 path,
457 start_line,
458 end_line,
459 ..
460 } if start_line == end_line => format!("{path}:{start_line}"),
461 Self::Passage {
462 path,
463 start_line,
464 end_line,
465 ..
466 } => format!("{path}:{start_line}-{end_line}"),
467 Self::DiffLine {
468 path, side, line, ..
469 } => format!("{path}:{line} ({side})"),
470 Self::TestLine {
471 req, path, line, ..
472 } => format!("{req} {path}:{line}"),
473 Self::Finding { source, id, .. } => format!("{source} {id}"),
474 }
475 }
476}
477
478#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
480#[serde(rename_all = "snake_case")]
481pub enum DraftState {
482 Draft,
484 Outdated,
486 Sent,
488}
489
490#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
492pub struct CommentDraft {
493 pub id: String,
495 pub anchor: CommentAnchor,
497 pub text: String,
499 pub by: String,
501 pub at: u64,
503 pub state: DraftState,
505 #[serde(default)]
507 pub kept: bool,
508}
509
510#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
512pub struct RevisionView {
513 pub id: String,
515 pub comments: Vec<String>,
517 pub sections: Vec<String>,
519 pub skill: String,
521 pub changed: Vec<String>,
523 pub outside: Vec<String>,
525 pub outcome: String,
527 pub files: Vec<String>,
529 #[serde(default)]
531 pub asked_again: Vec<String>,
532 #[serde(default)]
535 pub beside: Vec<String>,
536 #[serde(default)]
539 pub diffs: Vec<SectionDiff>,
540}
541
542#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
545pub struct BugTests {
546 pub task: String,
548 pub title: String,
550 pub confirmation: crate::bug::Confirmation,
552 pub reproductions: Vec<BugTest>,
554}
555
556#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
558pub struct BugTest {
559 pub statement: String,
561 pub file: String,
563 pub test: String,
565 pub red_class: String,
567 pub content: Option<String>,
570}
571
572#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
575pub struct SectionDiff {
576 pub path: String,
578 pub heading: String,
580 pub before: String,
582 pub after: String,
584 pub outside: bool,
586}
587
588#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
590pub struct CommentsView {
591 pub feature: String,
593 pub drafts: Vec<CommentDraft>,
595 pub turn: Option<RevisionView>,
597 pub revise_with: Option<String>,
599}
600
601#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
603pub struct CodeComment {
604 pub path: String,
606 pub line: usize,
608 pub quote: String,
610 pub text: String,
612}
613
614impl CodeComment {
615 #[must_use]
617 pub fn nudge_line(&self) -> String {
618 let quote: String = self.quote.trim().chars().take(80).collect();
619 format!(
620 "{}:{} \"{}\" — {}",
621 self.path,
622 self.line,
623 quote,
624 self.text.trim()
625 )
626 }
627}
628
629#[must_use]
632pub fn normalise(text: &str) -> String {
633 text.split_whitespace()
634 .collect::<Vec<_>>()
635 .join(" ")
636 .to_lowercase()
637}
638
639#[must_use]
644pub fn compare(
645 a: &[Expectation],
646 a_untestable: &[Untestable],
647 b: &[Expectation],
648 b_untestable: &[Untestable],
649) -> Vec<Ambiguity> {
650 let mut out: Vec<Ambiguity> = Vec::new();
651 let mut push = |scenario: &str, ra: ReadingText, rb: ReadingText| {
652 if !out.iter().any(|x| x.scenario == scenario) {
653 out.push(Ambiguity {
654 scenario: scenario.to_owned(),
655 a: ra,
656 b: rb,
657 resolution: None,
658 });
659 }
660 };
661 let reading = |e: &Expectation| ReadingText {
662 given: e.given.clone(),
663 expect: e.expect.clone(),
664 };
665 let untestable = |u: &Untestable| ReadingText {
666 given: String::new(),
667 expect: format!("untestable: {}", u.why),
668 };
669 for ea in a {
670 for eb in b.iter().filter(|eb| eb.scenario == ea.scenario) {
671 if normalise(&ea.given) == normalise(&eb.given)
672 && normalise(&ea.expect) != normalise(&eb.expect)
673 {
674 push(&ea.scenario, reading(ea), reading(eb));
675 }
676 }
677 if let Some(u) = b_untestable.iter().find(|u| u.scenario == ea.scenario) {
678 push(&ea.scenario, reading(ea), untestable(u));
679 }
680 }
681 for eb in b {
682 if let Some(u) = a_untestable.iter().find(|u| u.scenario == eb.scenario) {
683 push(&eb.scenario, untestable(u), reading(eb));
684 }
685 }
686 out
687}
688
689#[cfg(test)]
690mod tests {
691 use proptest::prelude::*;
692
693 use super::*;
694
695 fn e(s: &str, given: &str, expect: &str) -> Expectation {
696 Expectation {
697 scenario: s.to_owned(),
698 given: given.to_owned(),
699 expect: expect.to_owned(),
700 }
701 }
702
703 #[test]
704 fn the_same_given_with_another_outcome_is_ambiguous() {
705 let a = [
706 e("US1-1", "name Ada", "Hello, Ada!"),
707 e("US1-2", "\"\"", "Hello!"),
708 ];
709 let b = [
710 e("US1-1", " Name ada ", "hello, ada!"),
711 e("US1-2", "\"\"", "an error"),
712 ];
713 let found = compare(&a, &[], &b, &[]);
714 assert_eq!(found.len(), 1);
715 assert_eq!(found[0].scenario, "US1-2");
716 assert_eq!(found[0].a.expect, "Hello!");
717 assert_eq!(found[0].b.expect, "an error");
718 assert!(found[0].open());
719 }
720
721 #[test]
722 fn different_givens_are_different_examples() {
723 let a = [e("US1-1", "name Ada", "Hello, Ada!")];
724 let b = [e("US1-1", "name Bob", "Hello, Bob!")];
725 assert!(compare(&a, &[], &b, &[]).is_empty());
726 }
727
728 #[test]
729 fn covered_by_one_and_untestable_for_the_other_is_ambiguous() {
730 let a = [e("US1-3", "offline", "an error")];
731 let u = [Untestable {
732 scenario: "US1-3".to_owned(),
733 why: "needs a network".to_owned(),
734 }];
735 let found = compare(&a, &[], &[], &u);
736 assert_eq!(found.len(), 1);
737 assert!(found[0].b.expect.starts_with("untestable"));
738 let back = compare(&[], &u, &a, &[]);
739 assert_eq!(back.len(), 1);
740 assert!(back[0].a.expect.starts_with("untestable"));
741 }
742
743 proptest! {
744 #[test]
745 fn agreeing_authors_raise_nothing(given in "[a-zA-Z ]{1,20}", expect in "[a-zA-Z ]{1,20}", pad in " {0,3}") {
746 let a = [e("S", &given, &expect)];
747 let b = [e("S", &format!("{pad}{}{pad}", given.to_uppercase()), &expect.to_lowercase())];
748 prop_assert!(compare(&a, &[], &b, &[]).is_empty());
749 }
750
751 #[test]
752 fn the_rule_is_symmetric_in_whether_it_fires(g in "[a-c]{1,3}", x in "[a-c]{1,3}", y in "[a-c]{1,3}") {
753 let a = [e("S", &g, &x)];
754 let b = [e("S", &g, &y)];
755 prop_assert_eq!(compare(&a, &[], &b, &[]).len(), compare(&b, &[], &a, &[]).len());
756 prop_assert_eq!(compare(&a, &[], &b, &[]).is_empty(), normalise(&x) == normalise(&y));
757 }
758 }
759
760 #[test]
761 fn the_sheet_says_what_approval_freezes() {
762 let mut v = OracleView {
763 req: "US1".to_owned(),
764 title: "Greet".to_owned(),
765 scenarios: Vec::new(),
766 state: OracleState::Written,
767 frozen_on: None,
768 frozen_by: None,
769 frozen_at: None,
770 tests: vec![OracleTest {
771 path: "tests/a.rs".to_owned(),
772 content: String::new(),
773 scenarios: vec!["US1-1".to_owned()],
774 red_class: "assertion".to_owned(),
775 red: true,
776 authored_by: AuthoredBy::Model,
777 edited_by: None,
778 }],
779 discarded: Vec::new(),
780 rejected: Vec::new(),
781 uncovered: Vec::new(),
782 untestable: Vec::new(),
783 ambiguities: Vec::new(),
784 held_out: HeldOutSummary {
785 state: "kept".to_owned(),
786 count: 2,
787 manifest_sha256: None,
788 reason: None,
789 discarded: Vec::new(),
790 },
791 strength_target: Some(80),
792 calls: 2,
793 ambiguity_check: true,
794 manifest_sha256: None,
795 error: None,
796 };
797 assert_eq!(v.freezes(), "1 test · 2 held out · target 80%");
798 v.tests[0].red = false;
799 assert_eq!(v.freezes(), "tests written at run time");
800 let line = CodeComment {
801 path: "src/a.rs".to_owned(),
802 line: 12,
803 quote: " let x = 1;".to_owned(),
804 text: " use a constant ".to_owned(),
805 };
806 assert_eq!(
807 line.nudge_line(),
808 "src/a.rs:12 \"let x = 1;\" — use a constant"
809 );
810 }
811}