Skip to main content

crev_data/proof/review/
package.rs

1use crate::{
2    Error, Level, ParseError,
3    proof::{
4        self, OverrideItem, OverrideItemDraft,
5        content::{OriginalReference, ValidationError, ValidationResult},
6    },
7    serde_content_serialize, serde_draft_serialize,
8};
9use crev_common::{self, is_equal_default, is_set_empty, is_vec_empty};
10use derive_builder::Builder;
11use proof::{CommonOps, Content};
12use semver::Version;
13use serde::{Deserialize, Serialize};
14use std::{
15    collections::HashSet,
16    default::Default,
17    fmt::{self, Debug},
18    ops,
19};
20use typed_builder::TypedBuilder;
21
22const CURRENT_PACKAGE_REVIEW_PROOF_SERIALIZATION_VERSION: i64 = -1;
23
24fn cur_version() -> i64 {
25    CURRENT_PACKAGE_REVIEW_PROOF_SERIALIZATION_VERSION
26}
27
28/// Possible flags to mark on the package
29#[derive(Clone, Builder, Debug, Serialize, Deserialize, Default, PartialEq, Eq)]
30pub struct Flags {
31    #[serde(default = "Default::default", skip_serializing_if = "is_equal_default")]
32    pub unmaintained: bool,
33}
34
35impl ops::Add<Flags> for Flags {
36    type Output = Self;
37    fn add(self, other: Flags) -> Self {
38        Self {
39            unmaintained: self.unmaintained || other.unmaintained,
40        }
41    }
42}
43
44impl From<FlagsDraft> for Flags {
45    fn from(flags: FlagsDraft) -> Self {
46        Self {
47            unmaintained: flags.unmaintained,
48        }
49    }
50}
51/// Like `Flags` but serializes all fields every time
52#[derive(Clone, Builder, Debug, Serialize, Deserialize, Default, PartialEq, Eq)]
53pub struct FlagsDraft {
54    #[serde(default = "Default::default")]
55    unmaintained: bool,
56}
57
58impl From<Flags> for FlagsDraft {
59    fn from(flags: Flags) -> Self {
60        Self {
61            unmaintained: flags.unmaintained,
62        }
63    }
64}
65
66/// Body of a Package Review Proof
67#[derive(Clone, Builder, Debug, Serialize, Deserialize)]
68// TODO: https://github.com/colin-kiegel/rust-derive-builder/issues/136
69pub struct Package {
70    #[serde(flatten)]
71    pub common: proof::Common,
72
73    #[serde(rename = "package")]
74    pub package: proof::PackageInfo,
75
76    #[serde(skip_serializing_if = "Option::is_none", default = "Default::default")]
77    #[serde(rename = "package-diff-base")]
78    #[builder(default = "Default::default()")]
79    pub diff_base: Option<proof::PackageInfo>,
80
81    #[builder(default = "Default::default()")]
82    #[serde(default = "Default::default", skip_serializing_if = "is_equal_default")]
83    review: super::Review,
84
85    #[builder(default = "Default::default()")]
86    #[serde(skip_serializing_if = "is_vec_empty", default = "Default::default")]
87    pub issues: Vec<Issue>,
88
89    #[builder(default = "Default::default()")]
90    #[serde(skip_serializing_if = "is_vec_empty", default = "Default::default")]
91    pub advisories: Vec<Advisory>,
92
93    #[serde(default = "Default::default", skip_serializing_if = "is_equal_default")]
94    #[builder(default = "Default::default()")]
95    pub flags: Flags,
96
97    #[builder(default = "Default::default()")]
98    #[serde(skip_serializing_if = "is_set_empty", default = "Default::default")]
99    pub alternatives: HashSet<proof::PackageId>,
100
101    #[serde(skip_serializing_if = "String::is_empty", default = "Default::default")]
102    #[builder(default = "Default::default()")]
103    pub comment: String,
104
105    #[builder(default = "Default::default()")]
106    #[serde(
107        default = "Default::default",
108        skip_serializing_if = "Vec::is_empty",
109        rename = "override"
110    )]
111    pub override_: Vec<OverrideItem>,
112
113    /// If present, indicates this review was performed by an LLM agent.
114    #[serde(skip_serializing_if = "Option::is_none", default, rename = "llm-agent")]
115    #[builder(default)]
116    pub llm_agent: Option<super::LlmAgentInfo>,
117}
118
119impl PackageBuilder {
120    pub fn from<VALUE: Into<crate::PublicId>>(&mut self, value: VALUE) -> &mut Self {
121        if let Some(ref mut common) = self.common {
122            common.from = value.into();
123        } else {
124            self.common = Some(proof::Common {
125                kind: Some(Package::KIND.into()),
126                version: cur_version(),
127                date: crev_common::now(),
128                from: value.into(),
129                original: None,
130            });
131        }
132        self
133    }
134}
135
136impl fmt::Display for Package {
137    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
138        self.serialize_to(f).map_err(|_| fmt::Error)
139    }
140}
141
142impl proof::WithReview for Package {
143    fn review(&self) -> &super::Review {
144        &self.review
145    }
146}
147
148impl proof::CommonOps for Package {
149    fn common(&self) -> &proof::Common {
150        &self.common
151    }
152
153    fn kind(&self) -> &str {
154        // Backfill the `kind` if it is empty (legacy format)
155        self.common.kind.as_deref().unwrap_or(Self::KIND)
156    }
157}
158
159impl Package {
160    pub fn touch_date(&mut self) {
161        self.common.date = crev_common::now();
162    }
163
164    pub fn change_from(&mut self, id: crate::PublicId) {
165        self.common.from = id;
166    }
167
168    pub fn set_original_reference(&mut self, orig_reference: OriginalReference) {
169        self.common.original = Some(orig_reference);
170    }
171
172    pub fn ensure_kind_is_backfilled(&mut self) {
173        if self.common.kind.is_none() {
174            // backfill "kind" for old reviews. Don't use common().kind() here, it will panic.
175            self.common.kind = Some(self.kind().to_string());
176        }
177    }
178}
179
180/// Like `Package` but serializes for interactive editing
181#[derive(Clone, Debug, Serialize, Deserialize)]
182pub struct Draft {
183    #[serde(default = "Default::default")]
184    review: super::Review,
185
186    #[serde(default = "Default::default", skip_serializing_if = "is_vec_empty")]
187    pub advisories: Vec<Advisory>,
188
189    #[serde(default = "Default::default", skip_serializing_if = "is_vec_empty")]
190    pub issues: Vec<Issue>,
191
192    #[serde(default = "Default::default", skip_serializing_if = "String::is_empty")]
193    comment: String,
194    #[serde(default = "Default::default")]
195    pub flags: FlagsDraft,
196
197    #[serde(default = "Default::default", skip_serializing_if = "is_set_empty")]
198    pub alternatives: HashSet<proof::PackageId>,
199
200    #[serde(
201        default = "Default::default",
202        skip_serializing_if = "Vec::is_empty",
203        rename = "override"
204    )]
205    pub override_: Vec<OverrideItemDraft>,
206}
207
208impl Draft {
209    pub fn parse(s: &str) -> std::result::Result<Self, ParseError> {
210        serde_yaml::from_str(s).map_err(ParseError::Draft)
211    }
212}
213
214impl From<Package> for Draft {
215    fn from(package: Package) -> Self {
216        Draft {
217            review: package.review,
218            advisories: package.advisories,
219            issues: package.issues,
220            comment: package.comment,
221            alternatives: if package.alternatives.is_empty() {
222                // To give user a convenient template, we pre-fill with the same `source`,
223                // and an empty `name`. If undedited, this entry will be deleted on parsing.
224                vec![proof::PackageId {
225                    source: package.package.id.id.source,
226                    name: String::new(),
227                }]
228                .into_iter()
229                .collect()
230            } else {
231                package.alternatives
232            },
233            flags: package.flags.into(),
234            override_: package.override_.into_iter().map(Into::into).collect(),
235        }
236    }
237}
238
239impl proof::Content for Package {
240    fn validate_data(&self) -> ValidationResult<()> {
241        self.ensure_kind_is(Self::KIND)?;
242
243        for alternative in &self.alternatives {
244            if alternative.source.is_empty() {
245                return Err(ValidationError::AlternativeSourceCanNotBeEmpty);
246            }
247            if alternative.name.is_empty() {
248                return Err(ValidationError::AlternativeNameCanNotBeEmpty);
249            }
250        }
251        for issue in &self.issues {
252            if issue.id.is_empty() {
253                return Err(ValidationError::IssuesWithAnEmptyIDFieldAreNotAllowed);
254            }
255        }
256
257        for advisory in &self.advisories {
258            if advisory.ids.is_empty() {
259                return Err(ValidationError::AdvisoriesWithNoIDSAreNotAllowed);
260            }
261
262            for id in &advisory.ids {
263                if id.is_empty() {
264                    return Err(ValidationError::AdvisoriesWithAnEmptyIDFieldAreNotAllowed);
265                }
266            }
267        }
268        Ok(())
269    }
270
271    fn serialize_to(&self, fmt: &mut dyn std::fmt::Write) -> fmt::Result {
272        serde_content_serialize!(self, fmt);
273        Ok(())
274    }
275}
276
277impl proof::ContentWithDraft for Package {
278    fn to_draft(&self) -> proof::Draft {
279        proof::Draft {
280            title: format!(
281                "Package Review of {} {}",
282                self.package.id.id.name, self.package.id.version
283            ),
284            body: Draft::from(self.clone()).to_string(),
285        }
286    }
287
288    fn apply_draft(&self, s: &str) -> Result<Self, Error> {
289        let draft = Draft::parse(s)?;
290
291        let mut package = self.clone();
292        package.review = draft.review;
293        package.comment = draft.comment;
294        package.advisories = draft.advisories;
295        package.issues = draft.issues;
296        package.alternatives = draft
297            .alternatives
298            .into_iter()
299            .filter(|a| !a.name.is_empty())
300            .collect();
301        package.flags = draft.flags.into();
302        package.override_ = draft.override_.into_iter().map(Into::into).collect();
303
304        package.validate_data()?;
305        Ok(package)
306    }
307}
308
309impl Package {
310    pub const KIND: &'static str = "package review";
311
312    #[must_use]
313    pub fn is_advisory_for(&self, version: &Version) -> bool {
314        for advisory in &self.advisories {
315            if advisory.is_for_version_when_reported_in_version(version, &self.package.id.version) {
316                return true;
317            }
318        }
319        false
320    }
321
322    /// Get the `Review`
323    ///
324    /// This forces the user to handle reviews that are
325    /// empty (everything is set to None) explicitly.
326    #[must_use]
327    pub fn review(&self) -> Option<&super::Review> {
328        if self.review.is_none() {
329            None
330        } else {
331            Some(&self.review)
332        }
333    }
334
335    /// Get the underlying review.
336    ///
337    /// The caller is responsible for handling the case where
338    /// `review.is_none()`.
339    #[must_use]
340    pub fn review_possibly_none(&self) -> &super::Review {
341        &self.review
342    }
343
344    pub fn review_possibly_none_mut(&mut self) -> &mut super::Review {
345        &mut self.review
346    }
347}
348
349impl fmt::Display for Draft {
350    fn fmt(&self, fmt: &mut fmt::Formatter<'_>) -> fmt::Result {
351        serde_draft_serialize!(self, fmt);
352        Ok(())
353    }
354}
355
356#[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord, Default)]
357#[serde(rename_all = "kebab-case")]
358pub enum VersionRange {
359    Minor,
360    Major,
361    #[default]
362    All,
363}
364
365#[derive(Debug, Clone)]
366pub struct VersionRangeParseError(());
367
368impl fmt::Display for VersionRangeParseError {
369    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
370        write!(f, "Could not parse an incorrect advisory range value")
371    }
372}
373
374impl std::str::FromStr for VersionRange {
375    type Err = VersionRangeParseError;
376
377    fn from_str(s: &str) -> std::result::Result<Self, Self::Err> {
378        Ok(match s {
379            "all" => VersionRange::All,
380            "major" => VersionRange::Major,
381            "minor" => VersionRange::Minor,
382            _ => return Err(VersionRangeParseError(())),
383        })
384    }
385}
386
387impl VersionRange {
388    fn all() -> Self {
389        VersionRange::All
390    }
391
392    #[allow(clippy::trivially_copy_pass_by_ref)]
393    fn is_all_ref(&self) -> bool {
394        VersionRange::All == *self
395    }
396}
397
398/// Advisory to upgrade to the package version
399///
400/// Advisory means a general important fix was included in this
401/// release, and all previous releases were potentially affected.
402/// We don't play with exact ranges.
403#[derive(Clone, TypedBuilder, Debug, Serialize, Deserialize)]
404#[serde(rename_all = "kebab-case")]
405#[derive(Default)]
406pub struct Advisory {
407    pub ids: Vec<String>,
408
409    #[builder(default)]
410    pub severity: Level,
411
412    #[builder(default)]
413    #[serde(
414        default = "VersionRange::all",
415        skip_serializing_if = "VersionRange::is_all_ref"
416    )]
417    pub range: VersionRange,
418
419    #[builder(default)]
420    #[serde(default = "Default::default")]
421    pub comment: String,
422}
423
424impl From<VersionRange> for Advisory {
425    fn from(r: VersionRange) -> Self {
426        Advisory {
427            range: r,
428            ..Default::default()
429        }
430    }
431}
432
433impl Advisory {
434    #[must_use]
435    pub fn is_for_version_when_reported_in_version(
436        &self,
437        for_version: &Version,
438        in_pkg_version: &Version,
439    ) -> bool {
440        if for_version < in_pkg_version {
441            match self.range {
442                VersionRange::All => return true,
443                VersionRange::Major => {
444                    if in_pkg_version.major == for_version.major {
445                        return true;
446                    }
447                }
448                VersionRange::Minor => {
449                    if in_pkg_version.major == for_version.major
450                        && in_pkg_version.minor == for_version.minor
451                    {
452                        return true;
453                    }
454                }
455            }
456        }
457        false
458    }
459}
460
461/// Issue with a package version
462///
463/// `Issue` is a kind of opposite of [`Advisory`]. It reports
464/// a problem with package in a given version. It leaves the
465/// question open if any previous and following versions might
466/// also be affected, but will be considered open and affecting
467/// all following versions within the `range` until an advisory
468/// is found for it, matching the id.
469#[derive(Clone, TypedBuilder, Debug, Serialize, Deserialize)]
470#[serde(rename_all = "kebab-case")]
471pub struct Issue {
472    pub id: String,
473    #[builder(default)]
474    pub severity: Level,
475
476    #[builder(default)]
477    #[serde(
478        default = "VersionRange::all",
479        skip_serializing_if = "VersionRange::is_all_ref"
480    )]
481    pub range: VersionRange,
482
483    #[builder(default)]
484    #[serde(default = "Default::default")]
485    pub comment: String,
486}
487
488impl Issue {
489    #[must_use]
490    pub fn new(id: String) -> Self {
491        Self {
492            id,
493            range: Default::default(),
494            severity: Default::default(),
495            comment: Default::default(),
496        }
497    }
498    #[must_use]
499    pub fn new_with_severity(id: String, severity: Level) -> Self {
500        Self {
501            id,
502            range: Default::default(),
503            severity,
504            comment: Default::default(),
505        }
506    }
507    #[must_use]
508    pub fn is_for_version_when_reported_in_version(
509        &self,
510        for_version: &Version,
511        in_pkg_version: &Version,
512    ) -> bool {
513        if for_version >= in_pkg_version {
514            match self.range {
515                VersionRange::All => return true,
516                VersionRange::Major => {
517                    if in_pkg_version.major == for_version.major {
518                        return true;
519                    }
520                }
521                VersionRange::Minor => {
522                    if in_pkg_version.major == for_version.major
523                        && in_pkg_version.minor == for_version.minor
524                    {
525                        return true;
526                    }
527                }
528            }
529        }
530        false
531    }
532}