Skip to main content

spar/
model.rs

1//! The shapes that cross the boundary between spar and a model, and the shapes
2//! spar keeps for itself.
3//!
4//! Everything a model produces is parsed leniently: an LLM that answers
5//! "medium" where the schema said "med" is not a reason to abandon a run that
6//! has already spent real money. Anything genuinely unrecognisable is still an
7//! error, because silently downgrading a blocking finding is worse than
8//! stopping.
9
10use std::collections::BTreeMap;
11use std::fmt;
12
13use serde::de::{self, Deserializer, Visitor};
14use serde::{Deserialize, Serialize, Serializer};
15
16fn norm_token(text: &str) -> String {
17    text.trim()
18        .to_lowercase()
19        .chars()
20        .filter(|c| c.is_ascii_alphanumeric())
21        .collect()
22}
23
24macro_rules! string_enum {
25    (
26        $(#[$meta:meta])*
27        pub enum $name:ident {
28            $( $(#[$vmeta:meta])* $variant:ident = $canonical:literal $( | $alias:literal )* ),+ $(,)?
29        }
30    ) => {
31        $(#[$meta])*
32        #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
33        pub enum $name { $( $(#[$vmeta])* $variant ),+ }
34
35        impl $name {
36            pub fn as_str(self) -> &'static str {
37                match self { $( $name::$variant => $canonical ),+ }
38            }
39
40            /// Accept the canonical spelling, any listed alias, and any
41            /// difference in case, spacing, hyphens, or underscores.
42            pub fn parse_lenient(text: &str) -> Option<Self> {
43                let got = norm_token(text);
44                $(
45                    if got == norm_token($canonical) $( || got == norm_token($alias) )* {
46                        return Some($name::$variant);
47                    }
48                )+
49                None
50            }
51
52            pub fn valid_values() -> String {
53                [$( $canonical ),+].join(", ")
54            }
55        }
56
57        impl fmt::Display for $name {
58            fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
59                f.write_str(self.as_str())
60            }
61        }
62
63        impl Serialize for $name {
64            fn serialize<S: Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
65                s.serialize_str(self.as_str())
66            }
67        }
68
69        impl<'de> Deserialize<'de> for $name {
70            fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
71                let raw = String::deserialize(d)?;
72                $name::parse_lenient(&raw).ok_or_else(|| {
73                    de::Error::custom(format!(
74                        "{} is not one of: {}",
75                        raw,
76                        $name::valid_values()
77                    ))
78                })
79            }
80        }
81    };
82}
83
84string_enum! {
85    /// How much work an issue is. Drives ordering: cheap unblocking work first.
86    pub enum Complexity {
87        S = "s" | "small" | "sm" | "xs" | "trivial",
88        M = "m" | "medium" | "med" | "moderate",
89        L = "l" | "large" | "lg" | "xl" | "big" | "huge",
90    }
91}
92
93string_enum! {
94    /// How likely a change here is to break something.
95    pub enum Risk {
96        Low = "low" | "l" | "minimal" | "none",
97        Med = "med" | "medium" | "m" | "moderate",
98        High = "high" | "h" | "severe" | "critical",
99    }
100}
101
102string_enum! {
103    /// Only `Blocking` gates a merge. This is the whole defence against the
104    /// nitpick spiral: a competent reviewer can always find something, so
105    /// "no objections remaining" is not a stopping condition but "no blocking
106    /// objections" is.
107    pub enum Severity {
108        Blocking = "blocking" | "block" | "major" | "critical",
109        NonBlocking = "non-blocking" | "nonblocking" | "non_blocking" | "minor" | "suggestion",
110        Nit = "nit" | "nitpick" | "style" | "trivial",
111    }
112}
113
114string_enum! {
115    pub enum Verdict {
116        Approve = "approve" | "approved" | "lgtm",
117        ChangesRequested = "changes_requested" | "changes-requested" | "request_changes" | "reject",
118    }
119}
120
121string_enum! {
122    pub enum NextAction {
123        Merge = "merge" | "approve" | "ship",
124        FixMyself = "fix_myself" | "fix-myself" | "fix" | "self_fix",
125        HandBack = "hand_back" | "hand-back" | "handback" | "return",
126    }
127}
128
129string_enum! {
130    /// A reviewer's point gets exactly one of these. Refutation is a first
131    /// class outcome, not friction: an agent that accepts every comment to get
132    /// approved produces worse code, not better.
133    pub enum Action {
134        Fixed = "fixed" | "fix" | "accepted" | "done",
135        Refuted = "refuted" | "refute" | "rejected" | "disagree" | "wontfix",
136        FiledIssue = "filed_issue" | "filed-issue" | "filed" | "deferred" | "out_of_scope",
137    }
138}
139
140string_enum! {
141    /// Terminal state of one issue or one resumed PR.
142    pub enum Status {
143        Pending = "pending",
144        Abandoned = "abandoned",
145        Approved = "approved",
146        Merged = "merged",
147        Escalated = "escalated",
148        Error = "error",
149        /// Review only: findings were produced and posted, nothing was changed.
150        Reviewed = "reviewed",
151        /// Review only: both reviewers found nothing that blocks a merge.
152        Clean = "clean",
153    }
154}
155
156impl Complexity {
157    pub fn rank(self) -> u8 {
158        match self {
159            Complexity::S => 0,
160            Complexity::M => 1,
161            Complexity::L => 2,
162        }
163    }
164}
165
166impl Severity {
167    /// How badly it matters, independent of the order the variants happen to
168    /// be declared in. Relying on derived `Ord` here would silently invert the
169    /// moment somebody reorders the enum.
170    pub fn rank(self) -> u8 {
171        match self {
172            Severity::Nit => 0,
173            Severity::NonBlocking => 1,
174            Severity::Blocking => 2,
175        }
176    }
177
178    /// The graver of two judgements.
179    ///
180    /// Two reviewers disagreeing about severity is resolved upward on purpose.
181    /// Nothing here gates a merge, it is all advice to a person, and advice
182    /// that under-reports a real defect is worse than advice that over-reports
183    /// a small one.
184    pub fn graver(self, other: Self) -> Self {
185        if self.rank() >= other.rank() {
186            self
187        } else {
188            other
189        }
190    }
191}
192
193impl Risk {
194    pub fn rank(self) -> u8 {
195        match self {
196            Risk::Low => 0,
197            Risk::Med => 1,
198            Risk::High => 2,
199        }
200    }
201}
202
203// ---------------------------------------------------------------------------
204// Lenient scalar helpers
205// ---------------------------------------------------------------------------
206
207/// An integer that may arrive as a number, a float, or a quoted string, with or
208/// without a leading `#`.
209pub fn de_i64<'de, D: Deserializer<'de>>(d: D) -> Result<i64, D::Error> {
210    struct V;
211    impl<'de> Visitor<'de> for V {
212        type Value = i64;
213        fn expecting(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
214            f.write_str("an issue number")
215        }
216        fn visit_i64<E: de::Error>(self, v: i64) -> Result<i64, E> {
217            Ok(v)
218        }
219        fn visit_u64<E: de::Error>(self, v: u64) -> Result<i64, E> {
220            Ok(v as i64)
221        }
222        fn visit_f64<E: de::Error>(self, v: f64) -> Result<i64, E> {
223            Ok(v as i64)
224        }
225        fn visit_str<E: de::Error>(self, v: &str) -> Result<i64, E> {
226            v.trim()
227                .trim_start_matches('#')
228                .parse()
229                .map_err(|_| E::custom(format!("{v} is not a number")))
230        }
231    }
232    d.deserialize_any(V)
233}
234
235fn de_i64_vec<'de, D: Deserializer<'de>>(d: D) -> Result<Vec<i64>, D::Error> {
236    #[derive(Deserialize)]
237    struct One(#[serde(deserialize_with = "de_i64")] i64);
238    let raw = Option::<Vec<One>>::deserialize(d)?;
239    Ok(raw
240        .unwrap_or_default()
241        .into_iter()
242        .map(|One(n)| n)
243        .collect())
244}
245
246/// A boolean that may arrive as `true`, `"true"`, `"yes"`, or `1`.
247pub fn de_bool<'de, D: Deserializer<'de>>(d: D) -> Result<bool, D::Error> {
248    struct V;
249    impl<'de> Visitor<'de> for V {
250        type Value = bool;
251        fn expecting(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
252            f.write_str("a boolean")
253        }
254        fn visit_bool<E: de::Error>(self, v: bool) -> Result<bool, E> {
255            Ok(v)
256        }
257        fn visit_i64<E: de::Error>(self, v: i64) -> Result<bool, E> {
258            Ok(v != 0)
259        }
260        fn visit_u64<E: de::Error>(self, v: u64) -> Result<bool, E> {
261            Ok(v != 0)
262        }
263        fn visit_str<E: de::Error>(self, v: &str) -> Result<bool, E> {
264            match norm_token(v).as_str() {
265                "true" | "yes" | "y" | "1" => Ok(true),
266                "false" | "no" | "n" | "0" => Ok(false),
267                other => Err(E::custom(format!("{other} is not a boolean"))),
268            }
269        }
270    }
271    d.deserialize_any(V)
272}
273
274fn de_bool_default_true<'de, D: Deserializer<'de>>(d: D) -> Result<bool, D::Error> {
275    #[derive(Deserialize)]
276    struct Wrap(#[serde(deserialize_with = "de_bool")] bool);
277    Ok(Option::<Wrap>::deserialize(d)?
278        .map(|Wrap(b)| b)
279        .unwrap_or(true))
280}
281
282fn de_string<'de, D: Deserializer<'de>>(d: D) -> Result<String, D::Error> {
283    Ok(Option::<String>::deserialize(d)?.unwrap_or_default())
284}
285
286// ---------------------------------------------------------------------------
287// What the models return
288// ---------------------------------------------------------------------------
289
290#[derive(Debug, Clone, Serialize, Deserialize)]
291pub struct TriageVerdict {
292    #[serde(deserialize_with = "de_i64")]
293    pub issue: i64,
294    #[serde(deserialize_with = "de_bool")]
295    pub worth_doing: bool,
296    #[serde(default, deserialize_with = "de_string")]
297    pub reason: String,
298    pub complexity: Complexity,
299    #[serde(default, deserialize_with = "de_i64_vec")]
300    pub depends_on: Vec<i64>,
301    pub risk: Risk,
302}
303
304#[derive(Debug, Clone, Serialize, Deserialize)]
305pub struct TriageResponse {
306    #[serde(default)]
307    pub issues: Vec<TriageVerdict>,
308}
309
310#[derive(Debug, Clone, Serialize, Deserialize)]
311pub struct Finding {
312    pub severity: Severity,
313    #[serde(default, deserialize_with = "de_string")]
314    pub title: String,
315    #[serde(default, deserialize_with = "de_string")]
316    pub detail: String,
317    #[serde(default, deserialize_with = "de_string")]
318    pub file: String,
319    /// A real problem that this PR did not cause. Those become follow-ups
320    /// rather than review comments, so they cannot gate an unrelated merge.
321    #[serde(default = "yes", deserialize_with = "de_bool_default_true")]
322    pub in_scope: bool,
323
324    // -- the parts of a bug report ---------------------------------------
325    //
326    // Filled when a finding is going to become an issue somebody picks up
327    // cold. `detail` is the one line the pull request thread shows; these are
328    // what a person needs when the thread is not in front of them. All
329    // optional: a finding that stays in the thread has no use for them.
330    /// What is wrong, with the specifics.
331    #[serde(default)]
332    pub problem: Option<String>,
333    /// Steps to reproduce it, and what actually happens.
334    #[serde(default)]
335    pub reproduction: Option<String>,
336    /// What it costs somebody.
337    #[serde(default)]
338    pub impact: Option<String>,
339    /// What it should do instead.
340    #[serde(default)]
341    pub expected: Option<String>,
342}
343
344impl Default for Finding {
345    /// A blank finding, for building one field at a time.
346    ///
347    /// Severity is spelled out here rather than derived, because a severity
348    /// arriving by default is exactly the mistake this codebase refuses
349    /// elsewhere: it is the field that decides whether a merge is gated, and
350    /// the least severe value is the only safe thing to assume.
351    fn default() -> Self {
352        Self {
353            severity: Severity::Nit,
354            title: String::new(),
355            detail: String::new(),
356            file: String::new(),
357            in_scope: true,
358            problem: None,
359            reproduction: None,
360            impact: None,
361            expected: None,
362        }
363    }
364}
365
366impl Finding {
367    /// The parts of a bug report this finding carries, in the order they are
368    /// written, skipping the ones it does not.
369    pub fn report_sections(&self) -> Vec<(&'static str, &str)> {
370        [
371            ("Problem", self.problem.as_deref()),
372            ("Reproduction", self.reproduction.as_deref()),
373            ("Impact", self.impact.as_deref()),
374            ("Expected behavior", self.expected.as_deref()),
375        ]
376        .into_iter()
377        .filter_map(|(heading, text)| {
378            text.map(str::trim)
379                .filter(|t| !t.is_empty())
380                .map(|t| (heading, t))
381        })
382        .collect()
383    }
384}
385
386fn yes() -> bool {
387    true
388}
389
390impl Finding {
391    pub fn blocks(&self) -> bool {
392        self.severity == Severity::Blocking && self.in_scope
393    }
394
395    pub fn where_at(&self) -> &str {
396        if self.file.trim().is_empty() {
397            "general"
398        } else {
399            self.file.trim()
400        }
401    }
402}
403
404#[derive(Debug, Clone, Serialize, Deserialize)]
405pub struct Review {
406    pub verdict: Verdict,
407    pub next_action: NextAction,
408    #[serde(default, deserialize_with = "de_string")]
409    pub summary: String,
410    #[serde(default)]
411    pub findings: Vec<Finding>,
412}
413
414#[derive(Debug, Clone, Serialize, Deserialize)]
415pub struct Disposition {
416    #[serde(default, deserialize_with = "de_string")]
417    pub title: String,
418    /// Carried so a refutation lands on the same ledger key the reviewer's
419    /// finding will hash to next round. Without it the re-litigation guard
420    /// silently never fires for any finding that names a file.
421    #[serde(default, deserialize_with = "de_string")]
422    pub file: String,
423    pub action: Action,
424    #[serde(default, deserialize_with = "de_string")]
425    pub reasoning: String,
426    #[serde(default)]
427    pub new_issue_title: Option<String>,
428    #[serde(default)]
429    pub new_issue_body: Option<String>,
430}
431
432/// One reviewer's judgement of a finding the *other* reviewer raised.
433///
434/// This is the whole point of review only mode. A finding both models raise
435/// independently is worth a maintainer's attention; a finding one raised and
436/// the other examined and rejected is usually not, and saying so is more useful
437/// than forwarding both.
438#[derive(Debug, Clone, Serialize, Deserialize)]
439pub struct Adjudication {
440    #[serde(default, deserialize_with = "de_string")]
441    pub title: String,
442    #[serde(default, deserialize_with = "de_string")]
443    pub file: String,
444    /// Whether the defect is real, judged by reading the code rather than by
445    /// deferring to the other reviewer.
446    #[serde(deserialize_with = "de_bool")]
447    pub agrees: bool,
448    /// This reviewer's own view of how badly it matters.
449    pub severity: Severity,
450    #[serde(default, deserialize_with = "de_string")]
451    pub reasoning: String,
452}
453
454#[derive(Debug, Clone, Serialize, Deserialize)]
455pub struct AdjudicationDoc {
456    #[serde(default)]
457    pub verdicts: Vec<Adjudication>,
458}
459
460/// A finding after both reviewers have had their say.
461#[derive(Debug, Clone)]
462pub struct Judged {
463    pub finding: Finding,
464    /// Who first raised it.
465    pub raised_by: String,
466    /// How it ended up.
467    pub standing: Standing,
468    /// The other reviewer's reasoning, when they had something to say.
469    pub counterpoint: Option<String>,
470    /// What the reviewer who raised it said when the objection came back.
471    /// Kept apart from the objection: running both together behind a single
472    /// "the other says" turns the most valuable content in the comment into
473    /// one unreadable sentence.
474    pub defence: Option<String>,
475}
476
477#[derive(Debug, Clone, Copy, PartialEq, Eq)]
478pub enum Standing {
479    /// Both reviewers raised it independently. The strongest signal there is.
480    Corroborated,
481    /// One raised it, the other read the code and agreed.
482    Confirmed,
483    /// One raised it, the other read the code and rejected it, and it survived
484    /// a rebuttal. A person decides.
485    Disputed,
486    /// Raised, rejected, and withdrawn by the reviewer who raised it.
487    Withdrawn,
488    /// Raised with nobody left to check it, because the round budget ran out.
489    Unverified,
490}
491
492/// What the implementor did, and what the pull request body is built from.
493///
494/// Structured for the reason every other exchange here is structured: a model
495/// asked for a description writes a paragraph about having written one, while a
496/// model asked for a problem, a change list, and a way to check them answers
497/// each of those. The body's substance comes from the fields being asked for
498/// separately, and its brevity from spar composing them rather than the model
499/// narrating.
500#[derive(Debug, Clone, Default, Serialize, Deserialize)]
501pub struct Implementation {
502    /// The issue should not be implemented. No commits, and `reason` says why.
503    #[serde(default, deserialize_with = "de_bool")]
504    pub not_worth_doing: bool,
505    /// Only when declining. Posted on the issue, so it is written for whoever
506    /// opened it rather than for the harness.
507    #[serde(default, deserialize_with = "de_string")]
508    pub reason: String,
509    /// One sentence saying what changed. Leads the body.
510    #[serde(default, deserialize_with = "de_string")]
511    pub summary: String,
512    /// What was actually wrong, as understood after reading the code. Not a
513    /// restatement of the issue: the reviewer can follow the link.
514    #[serde(default, deserialize_with = "de_string")]
515    pub problem: String,
516    /// One line per change that alters behaviour.
517    #[serde(default)]
518    pub changes: Vec<String>,
519    /// How a reviewer confirms the change works.
520    #[serde(default)]
521    pub testing: Vec<String>,
522    /// Anything the reviewer would otherwise have to ask about: a deliberate
523    /// omission, a decision worth defending, a risk. Usually nothing.
524    #[serde(default)]
525    pub notes: Option<String>,
526}
527
528#[derive(Debug, Clone, Serialize, Deserialize)]
529pub struct ResponseDoc {
530    #[serde(default, deserialize_with = "de_string")]
531    pub summary: String,
532    #[serde(default)]
533    pub dispositions: Vec<Disposition>,
534}
535
536// ---------------------------------------------------------------------------
537// What spar keeps
538// ---------------------------------------------------------------------------
539
540#[derive(Debug, Clone, Serialize, Deserialize)]
541pub struct PlanItem {
542    pub issue: i64,
543    pub title: String,
544    pub complexity: Complexity,
545    pub risk: Risk,
546    pub depends_on: Vec<i64>,
547    pub reason: String,
548}
549
550#[derive(Debug, Clone, Serialize, Deserialize)]
551pub struct SkippedItem {
552    pub issue: i64,
553    pub title: String,
554    /// Keyed by agent name, so the plan file says who said what.
555    pub reasons: BTreeMap<String, String>,
556}
557
558#[derive(Debug, Clone, Serialize, Deserialize)]
559pub struct ContestedItem {
560    pub issue: i64,
561    pub title: String,
562    /// Agent name to "do" or "skip".
563    pub positions: BTreeMap<String, String>,
564    pub reasons: BTreeMap<String, String>,
565    #[serde(default, skip_serializing_if = "Option::is_none")]
566    pub note: Option<String>,
567}
568
569#[derive(Debug, Clone, Default, Serialize, Deserialize)]
570pub struct Plan {
571    #[serde(default)]
572    pub order: Vec<PlanItem>,
573    #[serde(default)]
574    pub skipped: Vec<SkippedItem>,
575    #[serde(default)]
576    pub contested: Vec<ContestedItem>,
577}
578
579#[derive(Debug, Clone, Serialize, Deserialize)]
580pub struct LedgerEntry {
581    pub title: String,
582    pub file: String,
583    pub reasoning: String,
584    pub round: u32,
585    #[serde(default)]
586    pub reraised: u32,
587}
588
589/// Refuted points, keyed by `finding_key`. Ordered so the settled block in a
590/// prompt is stable between rounds, which keeps prompt caches warm and diffs
591/// readable.
592pub type Ledger = BTreeMap<String, LedgerEntry>;
593
594#[derive(Debug, Clone, Serialize, Deserialize)]
595pub struct Dispute {
596    pub title: String,
597    pub reasoning: String,
598}
599
600/// The outcome of working one issue, or resuming one PR.
601#[derive(Debug, Clone, Serialize, Deserialize)]
602pub struct IssueRun {
603    pub issue: i64,
604    pub title: String,
605    pub status: Status,
606    #[serde(default, skip_serializing_if = "Option::is_none")]
607    pub pr: Option<String>,
608    #[serde(default)]
609    pub rounds: u32,
610    #[serde(default)]
611    pub disputes: Vec<Dispute>,
612    #[serde(default)]
613    pub filed: Vec<String>,
614    #[serde(default)]
615    pub notes: Vec<String>,
616}
617
618impl IssueRun {
619    pub fn new(issue: i64, title: impl Into<String>) -> Self {
620        Self {
621            issue,
622            title: title.into(),
623            status: Status::Pending,
624            pr: None,
625            rounds: 0,
626            disputes: Vec::new(),
627            filed: Vec::new(),
628            notes: Vec::new(),
629        }
630    }
631
632    /// Whether this outcome counts as the run having done its job.
633    ///
634    /// A review that produced findings did its job: the findings are the
635    /// product, and a PR needing work is not a failure of the reviewer.
636    pub fn succeeded(&self) -> bool {
637        matches!(
638            self.status,
639            Status::Merged
640                | Status::Approved
641                | Status::Abandoned
642                | Status::Reviewed
643                | Status::Clean
644        )
645    }
646}
647
648/// Everything needed to pick a review back up, including on another machine.
649#[derive(Debug, Clone, Serialize, Deserialize)]
650pub struct PersistedState {
651    pub version: u32,
652    pub round: u32,
653    pub next_actor: String,
654    pub status: Status,
655    #[serde(default)]
656    pub ledger: Ledger,
657    #[serde(default)]
658    pub filed: Vec<String>,
659}
660
661pub const STATE_VERSION: u32 = 1;
662
663// ---------------------------------------------------------------------------
664// What gh returns
665// ---------------------------------------------------------------------------
666
667#[derive(Debug, Clone, Deserialize)]
668pub struct Label {
669    #[serde(default)]
670    pub name: String,
671}
672
673#[derive(Debug, Clone, Deserialize)]
674pub struct Issue {
675    pub number: i64,
676    #[serde(default)]
677    pub title: String,
678    #[serde(default)]
679    pub body: Option<String>,
680    #[serde(default)]
681    pub state: String,
682    #[serde(default)]
683    pub url: String,
684    #[serde(default)]
685    pub labels: Vec<Label>,
686}
687
688impl Issue {
689    pub fn body_text(&self) -> &str {
690        self.body.as_deref().unwrap_or("")
691    }
692
693    pub fn is_closed(&self) -> bool {
694        self.state.eq_ignore_ascii_case("closed")
695    }
696}
697
698#[derive(Debug, Clone, Deserialize)]
699pub struct PrRef {
700    pub number: i64,
701    #[serde(default)]
702    pub url: String,
703    #[serde(default)]
704    pub title: String,
705}
706
707#[derive(Debug, Clone, Deserialize)]
708pub struct IssueRef {
709    pub number: i64,
710}
711
712#[derive(Debug, Clone, Deserialize)]
713#[serde(rename_all = "camelCase")]
714pub struct PrView {
715    pub number: i64,
716    #[serde(default)]
717    pub url: String,
718    #[serde(default)]
719    pub title: String,
720    #[serde(default)]
721    pub head_ref_name: String,
722    #[serde(default)]
723    pub base_ref_name: String,
724    #[serde(default)]
725    pub state: String,
726    #[serde(default)]
727    pub closing_issues_references: Vec<IssueRef>,
728    /// True when the PR's head branch lives on a fork rather than this
729    /// repository.
730    #[serde(default)]
731    pub is_cross_repository: bool,
732}
733
734/// Issues and pull requests share one number sequence per repository, so a
735/// number names exactly one of them and spar can work out which.
736#[derive(Debug, Clone, Copy, PartialEq, Eq)]
737pub enum ItemKind {
738    Issue,
739    Pr,
740}
741
742impl std::fmt::Display for ItemKind {
743    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
744        f.write_str(match self {
745            ItemKind::Issue => "issue",
746            ItemKind::Pr => "pull request",
747        })
748    }
749}
750
751impl PrView {
752    pub fn is_open(&self) -> bool {
753        self.state.eq_ignore_ascii_case("open")
754    }
755}
756
757#[cfg(test)]
758mod tests {
759    use super::*;
760
761    #[test]
762    fn severity_accepts_the_canonical_spelling() {
763        assert_eq!(
764            Some(Severity::NonBlocking),
765            Severity::parse_lenient("non-blocking")
766        );
767    }
768
769    #[test]
770    fn severity_accepts_near_misses() {
771        for text in ["NonBlocking", "non_blocking", " NON-BLOCKING ", "minor"] {
772            assert_eq!(
773                Some(Severity::NonBlocking),
774                Severity::parse_lenient(text),
775                "{text}"
776            );
777        }
778    }
779
780    #[test]
781    fn severity_rejects_nonsense() {
782        assert_eq!(None, Severity::parse_lenient("catastrophic-ish"));
783    }
784
785    #[test]
786    fn complexity_ordering_is_cheapest_first() {
787        assert!(Complexity::S.rank() < Complexity::M.rank());
788        assert!(Complexity::M.rank() < Complexity::L.rank());
789    }
790
791    #[test]
792    fn finding_defaults_to_in_scope() {
793        let f: Finding = serde_json::from_value(serde_json::json!({
794            "severity": "blocking", "title": "t", "detail": "d", "file": "a.rs"
795        }))
796        .unwrap();
797        assert!(f.in_scope);
798        assert!(f.blocks());
799    }
800
801    #[test]
802    fn out_of_scope_blocking_does_not_block() {
803        let f: Finding = serde_json::from_value(serde_json::json!({
804            "severity": "blocking", "title": "t", "detail": "d",
805            "file": "a.rs", "in_scope": false
806        }))
807        .unwrap();
808        assert!(!f.blocks());
809    }
810
811    #[test]
812    fn triage_tolerates_a_quoted_issue_number() {
813        let v: TriageVerdict = serde_json::from_value(serde_json::json!({
814            "issue": "#42", "worth_doing": "yes", "reason": "r",
815            "complexity": "medium", "depends_on": ["39"], "risk": "low"
816        }))
817        .unwrap();
818        assert_eq!(42, v.issue);
819        assert!(v.worth_doing);
820        assert_eq!(Complexity::M, v.complexity);
821        assert_eq!(vec![39], v.depends_on);
822    }
823
824    #[test]
825    fn triage_tolerates_a_missing_depends_on() {
826        let v: TriageVerdict = serde_json::from_value(serde_json::json!({
827            "issue": 1, "worth_doing": false, "reason": "r",
828            "complexity": "s", "risk": "high"
829        }))
830        .unwrap();
831        assert!(v.depends_on.is_empty());
832    }
833
834    #[test]
835    fn review_tolerates_a_missing_findings_array() {
836        let r: Review = serde_json::from_value(serde_json::json!({
837            "verdict": "approve", "next_action": "merge", "summary": "fine"
838        }))
839        .unwrap();
840        assert!(r.findings.is_empty());
841    }
842
843    #[test]
844    fn a_null_reason_is_an_empty_string_not_a_failure() {
845        let v: TriageVerdict = serde_json::from_value(serde_json::json!({
846            "issue": 1, "worth_doing": true, "reason": null,
847            "complexity": "s", "depends_on": [], "risk": "low"
848        }))
849        .unwrap();
850        assert_eq!("", v.reason);
851    }
852
853    #[test]
854    fn unknown_severity_is_an_error_not_a_silent_downgrade() {
855        let out: Result<Finding, _> = serde_json::from_value(serde_json::json!({
856            "severity": "showstopper-maybe", "title": "t", "detail": "d", "file": "a.rs"
857        }));
858        assert!(out.is_err());
859    }
860
861    #[test]
862    fn status_round_trips_through_json() {
863        let run = IssueRun::new(4, "t");
864        let text = serde_json::to_string(&run).unwrap();
865        let back: IssueRun = serde_json::from_str(&text).unwrap();
866        assert_eq!(Status::Pending, back.status);
867    }
868}