Skip to main content

debversion/
lib.rs

1#![deny(missing_docs)]
2#![doc = include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/README.md"))]
3// Until we drop support for PyO3 0.22
4#![allow(deprecated)]
5
6use lazy_regex::{regex_captures, regex_is_match, regex_replace};
7use num_bigint::BigInt;
8use std::cmp::Ordering;
9use std::str::FromStr;
10
11pub mod upstream;
12pub mod vcs;
13pub mod vendor;
14
15/// A Debian version string
16///
17///
18#[derive(Debug, Clone)]
19pub struct Version {
20    /// The epoch of the version, if any
21    pub epoch: Option<u32>,
22
23    /// The upstream version
24    pub upstream_version: String,
25
26    /// The Debian revision, if any
27    pub debian_revision: Option<String>,
28}
29
30fn non_digit_cmp(va: &str, vb: &str) -> Ordering {
31    fn order(x: char) -> i32 {
32        match x {
33            '~' => -1,
34            '0'..='9' => unreachable!(),
35            'A'..='Z' | 'a'..='z' => x as i32,
36            _ => x as i32 + 256,
37        }
38    }
39
40    va.chars()
41        .map(order)
42        .chain(std::iter::repeat(0))
43        .zip(vb.chars().map(order).chain(std::iter::repeat(0)))
44        .take(va.len().max(vb.len()))
45        .find_map(|(a, b)| match a.cmp(&b) {
46            Ordering::Equal => None,
47            other => Some(other),
48        })
49        .unwrap_or(Ordering::Equal)
50}
51
52#[test]
53fn test_non_digit_cmp() {
54    assert_eq!(non_digit_cmp("a", "b"), Ordering::Less);
55    assert_eq!(non_digit_cmp("b", "a"), Ordering::Greater);
56    assert_eq!(non_digit_cmp("a", "a"), Ordering::Equal);
57    assert_eq!(non_digit_cmp("a", "-"), Ordering::Less);
58    assert_eq!(non_digit_cmp("a", "+"), Ordering::Less);
59    assert_eq!(non_digit_cmp("a", ""), Ordering::Greater);
60    assert_eq!(non_digit_cmp("", "a"), Ordering::Less);
61    assert_eq!(non_digit_cmp("", ""), Ordering::Equal);
62    assert_eq!(non_digit_cmp("~", ""), Ordering::Less);
63    assert_eq!(non_digit_cmp("~~", "~"), Ordering::Less);
64    assert_eq!(non_digit_cmp("~~", "~~a"), Ordering::Less);
65    assert_eq!(non_digit_cmp("~~a", "~"), Ordering::Less);
66    assert_eq!(non_digit_cmp("~", "a"), Ordering::Less);
67    // Test special characters that exercise the arithmetic on line 36
68    assert_eq!(non_digit_cmp("!", "@"), Ordering::Less); // ! = 33, @ = 64 + 256 = 320
69    assert_eq!(non_digit_cmp("@", "!"), Ordering::Greater);
70    assert_eq!(non_digit_cmp("#", "$"), Ordering::Less); // # = 35, $ = 36
71    assert_eq!(non_digit_cmp("|", "}"), Ordering::Less); // | = 124, } = 125
72}
73
74fn drop_leading_zeroes(s: &str) -> &str {
75    // Drop leading zeroes while the next character is a digit
76    let bytes = s.as_bytes();
77    let mut start = 0;
78    while start + 1 < bytes.len() && bytes[start] == b'0' && bytes[start + 1].is_ascii_digit() {
79        start += 1;
80    }
81    &s[start..]
82}
83
84fn version_cmp_part(mut a: &str, mut b: &str) -> Ordering {
85    while !a.is_empty() || !b.is_empty() {
86        // First, create a for the non-digit leading part of each string
87        let a_non_digit = &a[..a
88            .chars()
89            .position(|c| c.is_ascii_digit())
90            .unwrap_or(a.len())];
91        let b_non_digit = &b[..b
92            .chars()
93            .position(|c| c.is_ascii_digit())
94            .unwrap_or(b.len())];
95
96        // Compare the non-digit leading part
97        match non_digit_cmp(a_non_digit, b_non_digit) {
98            Ordering::Equal => (),
99            ordering => return ordering,
100        }
101
102        // Remove the non-digit leading part from the strings
103        a = &a[a_non_digit.len()..];
104        b = &b[b_non_digit.len()..];
105
106        // Then, create a slice for the digit part of each string
107        let a_digit = &a[..a
108            .chars()
109            .position(|c| !c.is_ascii_digit())
110            .unwrap_or(a.len())];
111        let b_digit = &b[..b
112            .chars()
113            .position(|c| !c.is_ascii_digit())
114            .unwrap_or(b.len())];
115
116        // Compare the digit part
117        let ordering = match (a_digit.len(), b_digit.len()) {
118            (0, 0) => Ordering::Equal,
119            (0, _) => Ordering::Less,
120            (_, 0) => Ordering::Greater,
121            // For small numbers that fit in u64, avoid BigInt allocation
122            (a_len, b_len) if a_len <= 19 && b_len <= 19 => {
123                match (a_digit.parse::<u64>(), b_digit.parse::<u64>()) {
124                    (Ok(a_num), Ok(b_num)) => a_num.cmp(&b_num),
125                    // Fallback to BigInt if parsing fails (shouldn't happen with valid version strings)
126                    _ => a_digit
127                        .parse::<BigInt>()
128                        .unwrap()
129                        .cmp(&b_digit.parse::<BigInt>().unwrap()),
130                }
131            }
132            // For very long digit sequences, use BigInt
133            _ => a_digit
134                .parse::<BigInt>()
135                .unwrap()
136                .cmp(&b_digit.parse::<BigInt>().unwrap()),
137        };
138
139        match ordering {
140            Ordering::Equal => (),
141            ordering => return ordering,
142        }
143
144        // Remove the digit part from the strings
145        a = &a[a_digit.len()..];
146        b = &b[b_digit.len()..];
147    }
148    Ordering::Equal
149}
150
151impl Ord for Version {
152    fn cmp(&self, other: &Self) -> std::cmp::Ordering {
153        let self_norm = self.explicit();
154        let other_norm = other.explicit();
155        if self_norm.0 != other_norm.0 {
156            return std::cmp::Ord::cmp(&self_norm.0, &other_norm.0);
157        }
158
159        match version_cmp_part(self_norm.1, other_norm.1) {
160            Ordering::Equal => (),
161            ordering => return ordering,
162        }
163
164        version_cmp_part(self_norm.2, other_norm.2)
165    }
166}
167
168impl PartialOrd for Version {
169    fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
170        Some(self.cmp(other))
171    }
172}
173
174impl PartialEq for Version {
175    fn eq(&self, other: &Self) -> bool {
176        self.partial_cmp(other) == Some(std::cmp::Ordering::Equal)
177    }
178}
179
180impl Eq for Version {}
181
182/// Error parsing a version string
183#[derive(Debug, PartialEq, Eq)]
184pub struct ParseError(String);
185
186impl std::fmt::Display for ParseError {
187    fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
188        f.write_str(&self.0)
189    }
190}
191
192impl std::error::Error for ParseError {}
193
194impl FromStr for Version {
195    type Err = ParseError;
196
197    fn from_str(text: &str) -> Result<Self, Self::Err> {
198        let (_, epoch, upstream_version, debian_revision) = if let Some(c) = regex_captures!(
199            r"^(?:(\d+):)?([A-Za-z0-9.+:~-]+?)(?:-([A-Za-z0-9+.~]+))?$",
200            text
201        ) {
202            c
203        } else {
204            return Err(ParseError(format!("Invalid version string: {}", text)));
205        };
206
207        let epoch = Some(epoch)
208            .filter(|e| !e.is_empty())
209            .map(|e| {
210                e.parse()
211                    .map_err(|e| ParseError(format!("Error parsing epoch: {}", e)))
212            })
213            .transpose()?;
214
215        let debian_revision = if debian_revision.is_empty() {
216            None
217        } else {
218            Some(debian_revision.to_string())
219        };
220
221        Ok(Version {
222            epoch,
223            upstream_version: upstream_version.to_string(),
224            debian_revision,
225        })
226    }
227}
228
229impl std::fmt::Display for Version {
230    fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
231        if let Some(epoch) = self.epoch.as_ref() {
232            write!(f, "{}:", epoch)?;
233        }
234        f.write_str(&self.upstream_version)?;
235        if let Some(debian_revision) = self.debian_revision.as_ref() {
236            write!(f, "-{}", debian_revision)?;
237        }
238        Ok(())
239    }
240}
241
242impl std::hash::Hash for Version {
243    fn hash<H: std::hash::Hasher>(&self, state: &mut H) {
244        (
245            self.epoch,
246            self.upstream_version.as_str(),
247            self.debian_revision.as_deref(),
248        )
249            .hash(state);
250    }
251}
252
253/// Append (or bump) the `+nmuN` suffix of a native package version.
254fn bump_native_nmu(upstream: &str) -> String {
255    if let Some((_, prefix, n)) = regex_captures!(r"^(.*)\+nmu(\d+)$", upstream) {
256        if let Ok(n) = n.parse::<u32>() {
257            return format!("{}+nmu{}", prefix, n + 1);
258        }
259    }
260    format!("{}+nmu1", upstream)
261}
262
263/// Compute the NMU Debian revision derived from a maintainer revision.
264///
265/// `1` becomes `1.1` and `2` becomes `2.1` (first NMU); `1.1` becomes
266/// `1.2` (further NMU). Returns `None` for revisions that don't follow
267/// the plain `integer` / `integer.integer` shape, where the correct NMU
268/// revision cannot be derived mechanically.
269fn bump_revision_nmu(rev: &str) -> Option<String> {
270    if regex_is_match!(r"^\d+$", rev) {
271        return Some(format!("{}.1", rev));
272    }
273    if let Some((_, prefix, n)) = regex_captures!(r"^(\d+)\.(\d+)$", rev) {
274        let n: u32 = n.parse().ok()?;
275        return Some(format!("{}.{}", prefix, n + 1));
276    }
277    None
278}
279
280impl Version {
281    /// Return explicit tuple of this version
282    ///
283    /// This will return an explicit 0 for epochs and debian revisions
284    /// that are not set.
285    fn explicit(&self) -> (u32, &str, &str) {
286        (
287            self.epoch.unwrap_or(0),
288            self.upstream_version.as_str(),
289            self.debian_revision.as_deref().unwrap_or("0"),
290        )
291    }
292
293    /// Parse a version string with lenient parsing rules.
294    ///
295    /// This method accepts version strings that may not strictly comply with Debian Policy,
296    /// such as versions containing underscores. While the standard `FromStr` implementation
297    /// enforces strict Debian Policy compliance, this method is more permissive to handle
298    /// real-world packages that may use non-standard characters.
299    ///
300    /// # Examples
301    ///
302    /// ```
303    /// use debversion::Version;
304    /// // This version contains underscores, which violates Debian Policy
305    /// // but may appear in real-world packages
306    /// let v = Version::parse_lenient("2.0.37+cvs.JCW_PRE2_2037-1").unwrap();
307    /// assert_eq!(v.upstream_version, "2.0.37+cvs.JCW_PRE2_2037");
308    /// assert_eq!(v.debian_revision, Some("1".to_string()));
309    /// ```
310    pub fn parse_lenient(text: &str) -> Result<Self, ParseError> {
311        let (_, epoch, upstream_version, debian_revision) = if let Some(c) = regex_captures!(
312            r"^(?:(\d+):)?([A-Za-z0-9.+:~_-]+?)(?:-([A-Za-z0-9+.~_]+))?$",
313            text
314        ) {
315            c
316        } else {
317            return Err(ParseError(format!("Invalid version string: {}", text)));
318        };
319
320        let epoch = Some(epoch)
321            .filter(|e| !e.is_empty())
322            .map(|e| {
323                e.parse()
324                    .map_err(|e| ParseError(format!("Error parsing epoch: {}", e)))
325            })
326            .transpose()?;
327
328        let debian_revision = if debian_revision.is_empty() {
329            None
330        } else {
331            Some(debian_revision.to_string())
332        };
333
334        Ok(Version {
335            epoch,
336            upstream_version: upstream_version.to_string(),
337            debian_revision,
338        })
339    }
340
341    /// Is this a binNMU?
342    ///
343    /// A binNMU is a binary-only NMU (Non-Maintainer Upload) where the source package is not
344    /// changed.
345    ///
346    /// Note that this checks for the presence of the `+b[:digit:]` suffix, which is not part of the Debian
347    /// Policy Manual, but it is commonly used to indicate a binNMU.
348    ///
349    /// # Examples
350    ///
351    /// ```
352    /// use debversion::Version;
353    /// assert!("1.0+b1".parse::<Version>().unwrap().is_bin_nmu());
354    /// assert!("1.0-1+b1".parse::<Version>().unwrap().is_bin_nmu());
355    /// assert!(!"1.0-1".parse::<Version>().unwrap().is_bin_nmu());
356    /// assert!(!"1.0".parse::<Version>().unwrap().is_bin_nmu());
357    /// ```
358    pub fn is_bin_nmu(&self) -> bool {
359        self.bin_nmu_count().is_some()
360    }
361
362    /// Return the binNMU count of this version
363    ///
364    /// This will return the binNMU count of this version, or None if this is not a binNMU.
365    pub fn bin_nmu_count(&self) -> Option<i32> {
366        fn bin_nmu_suffix(s: &str) -> Option<i32> {
367            s.split_once("+b").and_then(|(_, rest)| rest.parse().ok())
368        }
369        if let Some(debian_revision) = self.debian_revision.as_ref() {
370            bin_nmu_suffix(debian_revision)
371        } else {
372            bin_nmu_suffix(self.upstream_version.as_str())
373        }
374    }
375
376    /// Create a binNMU version from this version
377    ///
378    /// This will increment the binNMU suffix by one, or add a `+b1` suffix if there is no binNMU
379    /// suffix.
380    pub fn increment_bin_nmu(self) -> Version {
381        fn increment_bin_nmu_suffix(s: &str) -> String {
382            match s.split_once("+b") {
383                Some((prefix, rest)) => match rest.parse::<i32>() {
384                    Ok(num) => format!("{}+b{}", prefix, num + 1),
385                    Err(_) => format!("{}+b1", s),
386                },
387                None => format!("{}+b1", s),
388            }
389        }
390
391        if let Some(debian_revision) = self.debian_revision.as_ref() {
392            Version {
393                epoch: self.epoch,
394                upstream_version: self.upstream_version,
395                debian_revision: Some(increment_bin_nmu_suffix(debian_revision)),
396            }
397        } else {
398            Version {
399                epoch: self.epoch,
400                upstream_version: increment_bin_nmu_suffix(&self.upstream_version),
401                debian_revision: self.debian_revision,
402            }
403        }
404    }
405
406    /// Check if this version is a sourceful NMU
407    ///
408    /// A sourceful NMU is a Non-Maintainer Upload where the source package is changed.
409    ///
410    /// This recognises two conventions:
411    ///
412    /// * a `+nmu[:digit:]` suffix, used for native packages (and sometimes
413    ///   derivatives). This is not part of the Debian Policy Manual, but it
414    ///   is commonly used to indicate a sourceful NMU.
415    /// * the classic non-native form, where the Debian revision of the last
416    ///   maintainer upload is extended with a `.[:digit:]` component, e.g.
417    ///   `1` becomes `1.1` and `2.1` becomes `2.2`.
418    pub fn is_nmu(&self) -> bool {
419        self.nmu_count().is_some()
420    }
421
422    /// Return the sourceful NMU count of this version
423    ///
424    /// This will return the sourceful NMU count of this version, or None if this is not a
425    /// sourceful NMU. See [`Version::is_nmu`] for the conventions that are recognised.
426    pub fn nmu_count(&self) -> Option<i32> {
427        fn nmu_suffix(s: &str) -> Option<i32> {
428            s.split_once("+nmu").and_then(|(_, rest)| rest.parse().ok())
429        }
430        if let Some(debian_revision) = self.debian_revision.as_ref() {
431            // Non-native package: a `+nmuN` suffix, or the classic
432            // `maintainer-revision.N` form (e.g. `1.1`, `2.3`).
433            nmu_suffix(debian_revision).or_else(|| {
434                regex_captures!(r"^\d+\.(\d+)$", debian_revision).and_then(|(_, n)| n.parse().ok())
435            })
436        } else {
437            // Native package: only the `+nmuN` suffix applies.
438            nmu_suffix(self.upstream_version.as_str())
439        }
440    }
441
442    /// Check if this version is a backport.
443    ///
444    /// Backports use a `~bpoN+M` suffix, where `N` is the Debian release
445    /// number being targeted and `M` the backport revision, e.g.
446    /// `1.0-1~bpo12+1`. Such uploads are made by the maintainer and are
447    /// exempt from the sourceful NMU check.
448    ///
449    /// # Examples
450    ///
451    /// ```
452    /// use debversion::Version;
453    /// assert!("1.0-1~bpo12+1".parse::<Version>().unwrap().is_backport());
454    /// assert!("1.0-1~bpo11+2".parse::<Version>().unwrap().is_backport());
455    /// assert!(!"1.0-1".parse::<Version>().unwrap().is_backport());
456    /// ```
457    pub fn is_backport(&self) -> bool {
458        regex_is_match!(r"~bpo\d+\+\d+$", &self.to_string())
459    }
460
461    /// Check if this version is a stable or security update.
462    ///
463    /// Stable and security updates use a `~debNuM` or `+debNuM` suffix,
464    /// where `N` is the Debian release number and `M` the update revision,
465    /// e.g. `1.0-1+deb12u1`. Such uploads are exempt from the sourceful NMU
466    /// check.
467    ///
468    /// # Examples
469    ///
470    /// ```
471    /// use debversion::Version;
472    /// assert!("1.0-1+deb12u1".parse::<Version>().unwrap().is_stable_update());
473    /// assert!("1.0-1~deb11u2".parse::<Version>().unwrap().is_stable_update());
474    /// assert!(!"1.0-1".parse::<Version>().unwrap().is_stable_update());
475    /// ```
476    pub fn is_stable_update(&self) -> bool {
477        regex_is_match!(r"[~+]deb\d+u\d+$", &self.to_string())
478    }
479
480    /// Return canonicalized version of this version
481    ///
482    /// # Examples
483    ///
484    /// ```
485    /// use debversion::Version;
486    /// assert_eq!("1.0-0".parse::<Version>().unwrap().canonicalize(), "1.0".parse::<Version>().unwrap());
487    /// assert_eq!("1.0-1".parse::<Version>().unwrap().canonicalize(), "1.0-1".parse::<Version>().unwrap());
488    /// ```
489    pub fn canonicalize(&self) -> Version {
490        let epoch = match self.epoch {
491            Some(0) => None,
492            epoch => epoch,
493        };
494
495        let upstream_version_stripped = drop_leading_zeroes(&self.upstream_version);
496        let upstream_version = if upstream_version_stripped == self.upstream_version {
497            self.upstream_version.clone()
498        } else {
499            upstream_version_stripped.to_string()
500        };
501
502        let debian_revision = match self.debian_revision.as_ref() {
503            Some(r) if r.chars().all(|c| c == '0') => None,
504            None => None,
505            Some(revision) => {
506                let stripped = drop_leading_zeroes(revision);
507                if stripped == revision {
508                    Some(revision.clone())
509                } else {
510                    Some(stripped.to_string())
511                }
512            }
513        };
514
515        Version {
516            epoch,
517            upstream_version,
518            debian_revision,
519        }
520    }
521
522    /// Increment the Debian revision.
523    ///
524    /// For native packages, increment the upstream version number.
525    /// For other packages, increment the debian revision.
526    pub fn increment_debian(&mut self) {
527        if let Some(ref mut debian_revision) = self.debian_revision {
528            *debian_revision = regex_replace!(r"\d+$", debian_revision, |x: &str| {
529                (x.parse::<i32>().unwrap() + 1).to_string()
530            })
531            .into_owned();
532        } else {
533            self.upstream_version = regex_replace!(r"\d+$", &self.upstream_version, |x: &str| {
534                (x.parse::<i32>().unwrap() + 1).to_string()
535            })
536            .into_owned();
537        }
538    }
539
540    /// Return true if this is a native package
541    pub fn is_native(&self) -> bool {
542        self.debian_revision.is_none()
543    }
544
545    /// Create a sourceful NMU version from this version.
546    ///
547    /// For native packages (those without a Debian revision), this appends a
548    /// `+nmu1` suffix to the upstream version, or bumps an existing `+nmuN`
549    /// suffix.
550    ///
551    /// For non-native packages, the Debian revision is bumped following the
552    /// NMU convention: `1` becomes `1.1`, `2` becomes `2.1` (first NMU) and
553    /// `1.1` becomes `1.2` (further NMU). For revisions that don't follow the
554    /// plain `integer` or `integer.integer` shape (e.g. `1ubuntu1`), where the
555    /// correct NMU revision cannot be derived mechanically, a `+nmuN` suffix
556    /// is appended to the Debian revision instead.
557    ///
558    /// # Examples
559    ///
560    /// ```
561    /// use debversion::Version;
562    /// assert_eq!(
563    ///     "1.0".parse::<Version>().unwrap().bump_nmu(),
564    ///     "1.0+nmu1".parse().unwrap()
565    /// );
566    /// assert_eq!(
567    ///     "1.0+nmu1".parse::<Version>().unwrap().bump_nmu(),
568    ///     "1.0+nmu2".parse().unwrap()
569    /// );
570    /// assert_eq!(
571    ///     "1.0-1".parse::<Version>().unwrap().bump_nmu(),
572    ///     "1.0-1.1".parse().unwrap()
573    /// );
574    /// assert_eq!(
575    ///     "1.0-2.1".parse::<Version>().unwrap().bump_nmu(),
576    ///     "1.0-2.2".parse().unwrap()
577    /// );
578    /// ```
579    pub fn bump_nmu(self) -> Version {
580        if let Some(debian_revision) = self.debian_revision {
581            let debian_revision = bump_revision_nmu(&debian_revision)
582                .unwrap_or_else(|| bump_native_nmu(&debian_revision));
583            Version {
584                epoch: self.epoch,
585                upstream_version: self.upstream_version,
586                debian_revision: Some(debian_revision),
587            }
588        } else {
589            Version {
590                epoch: self.epoch,
591                upstream_version: bump_native_nmu(&self.upstream_version),
592                debian_revision: None,
593            }
594        }
595    }
596}
597
598#[cfg(feature = "sqlx")]
599use sqlx::{postgres::PgTypeInfo, Postgres};
600
601#[cfg(feature = "sqlx")]
602impl sqlx::Type<Postgres> for Version {
603    fn type_info() -> PgTypeInfo {
604        PgTypeInfo::with_name("debversion")
605    }
606}
607
608#[cfg(feature = "sqlx")]
609impl sqlx::Encode<'_, Postgres> for Version {
610    fn encode_by_ref(
611        &self,
612        buf: &mut sqlx::postgres::PgArgumentBuffer,
613    ) -> Result<sqlx::encode::IsNull, Box<dyn std::error::Error + Send + Sync>> {
614        let version_str = self.to_string();
615        sqlx::Encode::<Postgres>::encode_by_ref(&version_str.as_str(), buf)
616    }
617}
618
619#[cfg(feature = "sqlx")]
620impl sqlx::Decode<'_, Postgres> for Version {
621    fn decode(
622        value: sqlx::postgres::PgValueRef<'_>,
623    ) -> Result<Self, Box<dyn std::error::Error + Send + Sync>> {
624        let s: &str = sqlx::Decode::<Postgres>::decode(value)?;
625        Ok(s.parse::<Version>()?)
626    }
627}
628
629#[cfg(all(feature = "sqlx", test))]
630mod sqlx_tests {
631    #[test]
632    fn type_info() {
633        use super::Version;
634        use sqlx::postgres::PgTypeInfo;
635        use sqlx::Type;
636
637        assert_eq!(PgTypeInfo::with_name("debversion"), Version::type_info());
638    }
639}
640
641#[cfg(feature = "python-debian")]
642use pyo3::prelude::*;
643
644#[cfg(feature = "python-debian")]
645impl FromPyObject<'_, '_> for Version {
646    type Error = PyErr;
647
648    fn extract(ob: pyo3::Borrowed<'_, '_, PyAny>) -> PyResult<Self> {
649        let debian_support = Python::import(ob.py(), "debian.debian_support")?;
650        let version_cls = debian_support.getattr("Version")?;
651        if !ob.is_instance(&version_cls)? {
652            return Err(pyo3::exceptions::PyTypeError::new_err("Expected a Version"));
653        }
654        Ok(Version {
655            epoch: ob
656                .getattr("epoch")?
657                .extract::<Option<String>>()?
658                .map(|s| s.parse().unwrap()),
659            upstream_version: ob.getattr("upstream_version")?.extract::<String>()?,
660            debian_revision: ob.getattr("debian_revision")?.extract::<Option<String>>()?,
661        })
662    }
663}
664
665#[cfg(feature = "python-debian")]
666impl<'py> IntoPyObject<'py> for Version {
667    type Target = PyAny;
668
669    type Output = Bound<'py, Self::Target>;
670
671    type Error = PyErr;
672
673    fn into_pyobject(self, py: Python<'py>) -> Result<Self::Output, Self::Error> {
674        let debian_support = py.import("debian.debian_support").unwrap();
675        let version_cls = debian_support.getattr("Version").unwrap();
676        version_cls.call1((self.to_string(),))
677    }
678}
679
680#[cfg(feature = "python-debian")]
681mod python_tests {
682    #[test]
683    fn test_from_pyobject() {
684        use super::Version;
685        use pyo3::prelude::*;
686        use std::ffi::CString;
687
688        Python::attach(|py| {
689            let globals = pyo3::types::PyDict::new(py);
690            globals
691                .set_item(
692                    "debian_support",
693                    py.import("debian.debian_support").unwrap(),
694                )
695                .unwrap();
696            let v = py
697                .eval(
698                    &CString::new("debian_support.Version('1.0-1')").unwrap(),
699                    Some(&globals),
700                    None,
701                )
702                .unwrap()
703                .extract::<Version>()
704                .unwrap();
705            assert_eq!(
706                v,
707                Version {
708                    epoch: None,
709                    upstream_version: "1.0".to_string(),
710                    debian_revision: Some("1".to_string())
711                }
712            );
713        });
714    }
715
716    #[test]
717    fn test_to_pyobject() {
718        use super::Version;
719        use pyo3::prelude::*;
720
721        Python::attach(|py| {
722            let v = Version {
723                epoch: Some(1),
724                upstream_version: "1.0".to_string(),
725                debian_revision: Some("1".to_string()),
726            };
727            let v = v.into_pyobject(py).unwrap();
728            let expected: Version = "1:1.0-1".parse().unwrap();
729            assert_eq!(v.get_type().name().unwrap(), "Version");
730            assert_eq!(v.unbind().extract::<Version>(py).unwrap(), expected);
731        });
732    }
733
734    #[test]
735    fn test_from_pyobject_error() {
736        use super::Version;
737        use pyo3::prelude::*;
738        use std::ffi::CString;
739
740        Python::attach(|py| {
741            // Test that extracting from a non-Version object fails
742            let string_obj = py
743                .eval(&CString::new("'not a version'").unwrap(), None, None)
744                .unwrap();
745            let result = string_obj.extract::<Version>();
746            assert!(result.is_err());
747        });
748    }
749}
750
751#[cfg(feature = "serde")]
752impl serde::Serialize for Version {
753    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
754    where
755        S: serde::Serializer,
756    {
757        serializer.serialize_str(&self.to_string())
758    }
759}
760
761#[cfg(feature = "serde")]
762impl<'de> serde::Deserialize<'de> for Version {
763    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
764    where
765        D: serde::Deserializer<'de>,
766    {
767        let concatenated: String = String::deserialize(deserializer)?;
768        concatenated.parse().map_err(serde::de::Error::custom)
769    }
770}
771
772/// Trait for converting an argument into a Version
773pub trait AsVersion {
774    /// Convert the argument into a Version
775    fn into_version(self) -> Result<Version, ParseError>;
776}
777
778impl AsVersion for &str {
779    fn into_version(self) -> Result<Version, ParseError> {
780        self.parse()
781    }
782}
783
784impl AsVersion for String {
785    fn into_version(self) -> Result<Version, ParseError> {
786        self.parse()
787    }
788}
789
790impl AsVersion for Version {
791    fn into_version(self) -> Result<Version, ParseError> {
792        Ok(self)
793    }
794}
795
796#[cfg(test)]
797mod tests {
798    use super::{version_cmp_part, ParseError, Version};
799    use std::cmp::Ordering;
800
801    #[test]
802    fn test_canonicalize() {
803        assert_eq!(
804            "1.0-1".parse::<Version>().unwrap().canonicalize(),
805            "1.0-1".parse::<Version>().unwrap()
806        );
807        assert_eq!(
808            "1.0-0".parse::<Version>().unwrap().canonicalize(),
809            "1.0".parse::<Version>().unwrap()
810        );
811        assert_eq!(
812            "0:1.0-2".parse::<Version>().unwrap().canonicalize(),
813            "1.0-2".parse::<Version>().unwrap()
814        );
815        assert_eq!(
816            "0001.0-0".parse::<Version>().unwrap().canonicalize(),
817            "1.0".parse::<Version>().unwrap()
818        );
819        assert_eq!(
820            "000.1".parse::<Version>().unwrap().canonicalize(),
821            "0.1".parse::<Version>().unwrap()
822        );
823    }
824
825    #[test]
826    fn test_explicit() {
827        assert_eq!(
828            (0, "1.0", "1"),
829            "1.0-1".parse::<Version>().unwrap().explicit()
830        );
831        assert_eq!(
832            (1, "1.0", "1"),
833            "1:1.0-1".parse::<Version>().unwrap().explicit()
834        );
835        assert_eq!(
836            (0, "1.0", "0"),
837            "1.0".parse::<Version>().unwrap().explicit()
838        );
839        assert_eq!(
840            (0, "1.0", "0"),
841            "1.0-0".parse::<Version>().unwrap().explicit()
842        );
843        assert_eq!(
844            (1, "1.0", "0"),
845            "1:1.0-0".parse::<Version>().unwrap().explicit()
846        );
847        assert_eq!(
848            (0, "000.1", "0"),
849            "000.1".parse::<Version>().unwrap().explicit()
850        );
851    }
852
853    macro_rules! assert_cmp(
854        ($a:expr, $b:expr, $cmp:tt) => {
855            assert_eq!($a.parse::<Version>().unwrap().cmp(&$b.parse::<Version>().unwrap()), std::cmp::Ordering::$cmp);
856        }
857    );
858
859    #[test]
860    fn test_version_cmp_part() {
861        assert_eq!(version_cmp_part("1.0", "1.0"), Ordering::Equal);
862        assert_eq!(version_cmp_part("0.1", "0.1"), Ordering::Equal);
863        assert_eq!(version_cmp_part("000.1", "0.1"), Ordering::Equal);
864        assert_eq!(version_cmp_part("1.0", "2.0"), Ordering::Less);
865        assert_eq!(version_cmp_part("1.0", "0.0"), Ordering::Greater);
866        assert_eq!(version_cmp_part("10.0", "2.0"), Ordering::Greater);
867        assert_eq!(version_cmp_part("1.0~rc1", "1.0"), Ordering::Less);
868    }
869
870    #[test]
871    fn test_cmp() {
872        assert_cmp!("1.0-1", "1.0-1", Equal);
873        assert_cmp!("1.0-1", "1.0-2", Less);
874        assert_cmp!("1.0-2", "1.0-1", Greater);
875        assert_cmp!("1.0-1", "1.0", Greater);
876        assert_cmp!("1.0", "1.0-1", Less);
877        assert_cmp!("2.50.0", "10.0.1", Less);
878
879        // Epoch
880        assert_cmp!("1:1.0-1", "1.0-1", Greater);
881        assert_cmp!("1.0-1", "1:1.0-1", Less);
882        assert_cmp!("1:1.0-1", "1:1.0-1", Equal);
883        assert_cmp!("1:1.0-1", "2:1.0-1", Less);
884        assert_cmp!("2:1.0-1", "1:1.0-1", Greater);
885
886        // ~ symbol
887        assert_cmp!("1.0~rc1-1", "1.0-1", Less);
888        assert_cmp!("1.0-1", "1.0~rc1-1", Greater);
889        assert_cmp!("1.0~rc1-1", "1.0~rc1-1", Equal);
890        assert_cmp!("1.0~rc1-1", "1.0~rc2-1", Less);
891        assert_cmp!("1.0~rc2-1", "1.0~rc1-1", Greater);
892
893        // letters
894        assert_cmp!("1.0a-1", "1.0-1", Greater);
895        assert_cmp!("1.0-1", "1.0a-1", Less);
896        assert_cmp!("1.0a-1", "1.0a-1", Equal);
897
898        // Bug 27
899        assert_cmp!("23.13.9-7", "0.6.45-2", Greater);
900    }
901
902    #[test]
903    fn test_parse() {
904        assert_eq!(
905            Version {
906                epoch: None,
907                upstream_version: "1.0".to_string(),
908                debian_revision: Some("1".to_string())
909            },
910            "1.0-1".parse().unwrap()
911        );
912
913        assert_eq!(
914            Version {
915                epoch: None,
916                upstream_version: "1.0".to_string(),
917                debian_revision: None
918            },
919            "1.0".parse().unwrap()
920        );
921
922        assert_eq!(
923            Version {
924                epoch: Some(1),
925                upstream_version: "1.0".to_string(),
926                debian_revision: Some("1".to_string())
927            },
928            "1:1.0-1".parse().unwrap()
929        );
930        assert_eq!(
931            "1:;a".parse::<Version>().unwrap_err(),
932            ParseError("Invalid version string: 1:;a".to_string())
933        );
934    }
935
936    #[test]
937    fn test_parse_error_display() {
938        let error = ParseError("test error message".to_string());
939        assert_eq!(format!("{}", error), "test error message");
940        assert_eq!(error.to_string(), "test error message");
941    }
942
943    #[test]
944    fn test_to_string() {
945        assert_eq!(
946            "1.0-1",
947            Version {
948                epoch: None,
949                upstream_version: "1.0".to_string(),
950                debian_revision: Some("1".to_string())
951            }
952            .to_string()
953        );
954        assert_eq!(
955            "1.0",
956            Version {
957                epoch: None,
958                upstream_version: "1.0".to_string(),
959                debian_revision: None,
960            }
961            .to_string()
962        );
963    }
964
965    #[test]
966    fn test_eq() {
967        assert_eq!(
968            "1.0-1".parse::<Version>().unwrap(),
969            "1.0-1".parse::<Version>().unwrap()
970        );
971    }
972
973    #[test]
974    fn test_hash() {
975        use std::collections::hash_map::DefaultHasher;
976        use std::hash::{Hash, Hasher};
977
978        let mut hasher1 = DefaultHasher::new();
979        let mut hasher2 = DefaultHasher::new();
980        let mut hasher3 = DefaultHasher::new();
981
982        "1.0-1".parse::<Version>().unwrap().hash(&mut hasher1);
983        "1.0-1".parse::<Version>().unwrap().hash(&mut hasher2);
984        "0:1.0-1".parse::<Version>().unwrap().hash(&mut hasher3);
985
986        let hash1 = hasher1.finish();
987        let hash2 = hasher2.finish();
988        let hash3 = hasher3.finish();
989
990        assert_eq!(hash1, hash2);
991        assert_ne!(hash1, hash3);
992    }
993
994    #[test]
995    fn to_string() {
996        assert_eq!(
997            "1.0-1",
998            Version {
999                epoch: None,
1000                upstream_version: "1.0".to_string(),
1001                debian_revision: Some("1".to_string())
1002            }
1003            .to_string()
1004        );
1005        assert_eq!(
1006            "1.0",
1007            Version {
1008                epoch: None,
1009                upstream_version: "1.0".to_string(),
1010                debian_revision: None,
1011            }
1012            .to_string()
1013        );
1014        assert_eq!(
1015            "1:1.0",
1016            Version {
1017                epoch: Some(1),
1018                upstream_version: "1.0".to_string(),
1019                debian_revision: None,
1020            }
1021            .to_string()
1022        );
1023    }
1024
1025    #[test]
1026    fn partial_eq() {
1027        assert!("1.0-1"
1028            .parse::<Version>()
1029            .unwrap()
1030            .eq(&"1.0-1".parse::<Version>().unwrap()));
1031    }
1032
1033    #[test]
1034    fn increment() {
1035        let mut v = "1.0-1".parse::<Version>().unwrap();
1036        v.increment_debian();
1037
1038        assert_eq!("1.0-2".parse::<Version>().unwrap(), v);
1039
1040        let mut v = "1.0".parse::<Version>().unwrap();
1041        v.increment_debian();
1042        assert_eq!("1.1".parse::<Version>().unwrap(), v);
1043
1044        let mut v = "1.0ubuntu1".parse::<Version>().unwrap();
1045        v.increment_debian();
1046        assert_eq!("1.0ubuntu2".parse::<Version>().unwrap(), v);
1047
1048        let mut v = "1.0-0ubuntu1".parse::<Version>().unwrap();
1049        v.increment_debian();
1050        assert_eq!("1.0-0ubuntu2".parse::<Version>().unwrap(), v);
1051    }
1052
1053    #[test]
1054    fn is_native() {
1055        assert!(!"1.0-1".parse::<Version>().unwrap().is_native());
1056        assert!("1.0".parse::<Version>().unwrap().is_native());
1057        assert!(!"1.0-0".parse::<Version>().unwrap().is_native());
1058    }
1059
1060    #[test]
1061    fn test_is_binnmu() {
1062        assert!("1.0+b1".parse::<Version>().unwrap().is_bin_nmu());
1063        assert!("1.0-1+b1".parse::<Version>().unwrap().is_bin_nmu());
1064        assert!(!"1.0-1".parse::<Version>().unwrap().is_bin_nmu());
1065        assert!(!"1.0".parse::<Version>().unwrap().is_bin_nmu());
1066    }
1067
1068    #[test]
1069    fn test_bin_nmu_count() {
1070        assert_eq!(
1071            Some(1),
1072            "1.0+b1".parse::<Version>().unwrap().bin_nmu_count()
1073        );
1074        assert_eq!(
1075            Some(1),
1076            "1.0-1+b1".parse::<Version>().unwrap().bin_nmu_count()
1077        );
1078        assert_eq!(None, "1.0-1".parse::<Version>().unwrap().bin_nmu_count());
1079        assert_eq!(None, "1.0".parse::<Version>().unwrap().bin_nmu_count());
1080    }
1081
1082    #[test]
1083    fn test_increment_bin_nmu() {
1084        assert_eq!(
1085            "1.0+b2".parse::<Version>().unwrap(),
1086            "1.0+b1".parse::<Version>().unwrap().increment_bin_nmu()
1087        );
1088        assert_eq!(
1089            "1.0-1+b2".parse::<Version>().unwrap(),
1090            "1.0-1+b1".parse::<Version>().unwrap().increment_bin_nmu()
1091        );
1092        assert_eq!(
1093            "1.0+b1".parse::<Version>().unwrap(),
1094            "1.0".parse::<Version>().unwrap().increment_bin_nmu()
1095        );
1096        assert_eq!(
1097            "1.0-1+b1".parse::<Version>().unwrap(),
1098            "1.0-1".parse::<Version>().unwrap().increment_bin_nmu()
1099        );
1100    }
1101
1102    #[test]
1103    fn test_nmu_count() {
1104        // `+nmuN` suffix style.
1105        assert_eq!(Some(1), "1.0+nmu1".parse::<Version>().unwrap().nmu_count());
1106        assert_eq!(
1107            Some(1),
1108            "1.0-1+nmu1".parse::<Version>().unwrap().nmu_count()
1109        );
1110        assert_eq!(
1111            Some(2),
1112            "1.0-1ubuntu1+nmu2".parse::<Version>().unwrap().nmu_count()
1113        );
1114
1115        // Classic non-native `maintainer-revision.N` style.
1116        assert_eq!(Some(1), "1.0-1.1".parse::<Version>().unwrap().nmu_count());
1117        assert_eq!(Some(1), "1.0-2.1".parse::<Version>().unwrap().nmu_count());
1118        assert_eq!(Some(3), "1.0-2.3".parse::<Version>().unwrap().nmu_count());
1119
1120        // Plain maintainer uploads are not NMUs.
1121        assert_eq!(None, "1.0-1".parse::<Version>().unwrap().nmu_count());
1122        assert_eq!(None, "1.0".parse::<Version>().unwrap().nmu_count());
1123        assert_eq!(None, "1.0-1ubuntu1".parse::<Version>().unwrap().nmu_count());
1124        // The `.N` form only applies to the Debian revision, not the
1125        // upstream version of a native package.
1126        assert_eq!(None, "1.1".parse::<Version>().unwrap().nmu_count());
1127    }
1128
1129    #[test]
1130    fn test_is_nmu() {
1131        assert!("1.0+nmu1".parse::<Version>().unwrap().is_nmu());
1132        assert!("1.0-1+nmu1".parse::<Version>().unwrap().is_nmu());
1133        assert!("1.0-1.1".parse::<Version>().unwrap().is_nmu());
1134        assert!("1.0-2.1".parse::<Version>().unwrap().is_nmu());
1135        assert!(!"1.0-1".parse::<Version>().unwrap().is_nmu());
1136        assert!(!"1.0".parse::<Version>().unwrap().is_nmu());
1137        assert!(!"1.1".parse::<Version>().unwrap().is_nmu());
1138    }
1139
1140    #[test]
1141    fn test_is_backport() {
1142        assert!("1.0-1~bpo12+1".parse::<Version>().unwrap().is_backport());
1143        assert!("1.0-1~bpo11+2".parse::<Version>().unwrap().is_backport());
1144        assert!("2:1.0-1~bpo12+1".parse::<Version>().unwrap().is_backport());
1145        // Native backport: the suffix is on the upstream version.
1146        assert!("1.0~bpo12+1".parse::<Version>().unwrap().is_backport());
1147        assert!(!"1.0-1".parse::<Version>().unwrap().is_backport());
1148        assert!(!"1.0".parse::<Version>().unwrap().is_backport());
1149        // A `~bpoN` without a `+M` revision is not matched.
1150        assert!(!"1.0-1~bpo12".parse::<Version>().unwrap().is_backport());
1151        // A stable update is not a backport.
1152        assert!(!"1.0-1+deb12u1".parse::<Version>().unwrap().is_backport());
1153    }
1154
1155    #[test]
1156    fn test_is_stable_update() {
1157        assert!("1.0-1+deb12u1"
1158            .parse::<Version>()
1159            .unwrap()
1160            .is_stable_update());
1161        assert!("1.0-1~deb11u2"
1162            .parse::<Version>()
1163            .unwrap()
1164            .is_stable_update());
1165        assert!("2:1.0-1+deb12u1"
1166            .parse::<Version>()
1167            .unwrap()
1168            .is_stable_update());
1169        // Native package: the suffix is on the upstream version.
1170        assert!("1.0+deb12u1".parse::<Version>().unwrap().is_stable_update());
1171        assert!(!"1.0-1".parse::<Version>().unwrap().is_stable_update());
1172        assert!(!"1.0".parse::<Version>().unwrap().is_stable_update());
1173        // A `+debN` without a `uM` revision is not matched.
1174        assert!(!"1.0-1+deb12".parse::<Version>().unwrap().is_stable_update());
1175        // The `~`/`+` separator is required.
1176        assert!(!"1.0-1deb12u1"
1177            .parse::<Version>()
1178            .unwrap()
1179            .is_stable_update());
1180        // A backport is not a stable update.
1181        assert!(!"1.0-1~bpo12+1"
1182            .parse::<Version>()
1183            .unwrap()
1184            .is_stable_update());
1185    }
1186
1187    #[test]
1188    fn test_comparing_very_long_versions() {
1189        // These are actual version numbers seen in actual apt repositories
1190        let a = "1:11.1.0~++20210314110124+1fdec59bffc1-1~exp1~20210314220751.162";
1191        let b = "1:11.1.0~++20211011013104+1fdec59bffc1-1~exp1~20211011133507.6";
1192        assert_cmp!(a, b, Less);
1193    }
1194
1195    #[test]
1196    fn test_drop_leading_zeroes() {
1197        use super::drop_leading_zeroes;
1198
1199        // Test basic cases
1200        assert_eq!(drop_leading_zeroes("1.0"), "1.0");
1201        assert_eq!(drop_leading_zeroes("001.0"), "1.0");
1202        assert_eq!(drop_leading_zeroes("000.1"), "0.1");
1203
1204        // Test edge cases for missed mutants
1205        assert_eq!(drop_leading_zeroes("0"), "0");
1206        assert_eq!(drop_leading_zeroes("00"), "0");
1207        assert_eq!(drop_leading_zeroes("0a"), "0a");
1208        assert_eq!(drop_leading_zeroes("01a"), "1a");
1209
1210        // Test single character
1211        assert_eq!(drop_leading_zeroes("a"), "a");
1212
1213        // Test empty string
1214        assert_eq!(drop_leading_zeroes(""), "");
1215    }
1216
1217    #[test]
1218    fn test_version_cmp_part_edge_cases() {
1219        // Test cases that should hit the missed mutants in version_cmp_part
1220
1221        // Test empty strings
1222        assert_eq!(version_cmp_part("", ""), Ordering::Equal);
1223        assert_eq!(version_cmp_part("", "1"), Ordering::Less);
1224        assert_eq!(version_cmp_part("1", ""), Ordering::Greater);
1225
1226        // Test digit comparison edge cases
1227        assert_eq!(version_cmp_part("1", "1"), Ordering::Equal);
1228        assert_eq!(version_cmp_part("01", "1"), Ordering::Equal);
1229
1230        // Test very long digit sequences to hit BigInt path
1231        let long_a = "123456789012345678901234567890";
1232        let long_b = "123456789012345678901234567891";
1233        assert_eq!(version_cmp_part(long_a, long_b), Ordering::Less);
1234
1235        // Test mixed digit/non-digit sequences
1236        assert_eq!(version_cmp_part("1a2", "1a3"), Ordering::Less);
1237        assert_eq!(version_cmp_part("1a2", "1b1"), Ordering::Less);
1238
1239        // Test tilde handling
1240        assert_eq!(version_cmp_part("1~", "1"), Ordering::Less);
1241        assert_eq!(version_cmp_part("~1", "1"), Ordering::Less);
1242    }
1243
1244    #[test]
1245    fn test_canonicalize_edge_cases() {
1246        // Test cases that should hit missed mutants in canonicalize
1247
1248        // Test epoch handling
1249        let v1 = Version {
1250            epoch: Some(0),
1251            upstream_version: "1.0".to_string(),
1252            debian_revision: None,
1253        };
1254        let canonical = v1.canonicalize();
1255        assert_eq!(canonical.epoch, None);
1256
1257        // Test debian revision all zeros
1258        let v2 = Version {
1259            epoch: None,
1260            upstream_version: "1.0".to_string(),
1261            debian_revision: Some("000".to_string()),
1262        };
1263        let canonical2 = v2.canonicalize();
1264        assert_eq!(canonical2.debian_revision, None);
1265
1266        // Test debian revision with leading zeros
1267        let v3 = Version {
1268            epoch: None,
1269            upstream_version: "1.0".to_string(),
1270            debian_revision: Some("001".to_string()),
1271        };
1272        let canonical3 = v3.canonicalize();
1273        assert_eq!(canonical3.debian_revision, Some("1".to_string()));
1274
1275        // Test upstream version with leading zeros
1276        let v4 = Version {
1277            epoch: None,
1278            upstream_version: "001.0".to_string(),
1279            debian_revision: None,
1280        };
1281        let canonical4 = v4.canonicalize();
1282        assert_eq!(canonical4.upstream_version, "1.0");
1283
1284        // Test unchanged cases
1285        let v5 = Version {
1286            epoch: Some(1),
1287            upstream_version: "1.0".to_string(),
1288            debian_revision: Some("1".to_string()),
1289        };
1290        let canonical5 = v5.canonicalize();
1291        assert_eq!(canonical5.upstream_version, "1.0");
1292        assert_eq!(canonical5.debian_revision, Some("1".to_string()));
1293    }
1294
1295    #[test]
1296    fn test_partial_eq_false() {
1297        // Test PartialEq returning false to catch the missed mutant
1298        assert!("1.0-1"
1299            .parse::<Version>()
1300            .unwrap()
1301            .ne(&"1.0-2".parse::<Version>().unwrap()));
1302
1303        assert!("1.0-1"
1304            .parse::<Version>()
1305            .unwrap()
1306            .ne(&"2.0-1".parse::<Version>().unwrap()));
1307
1308        assert!("1:1.0-1"
1309            .parse::<Version>()
1310            .unwrap()
1311            .ne(&"2:1.0-1".parse::<Version>().unwrap()));
1312    }
1313
1314    #[test]
1315    fn test_non_digit_cmp_edge_cases() {
1316        use super::non_digit_cmp;
1317
1318        // Test tilde vs regular chars
1319        assert_eq!(non_digit_cmp("~", "a"), Ordering::Less);
1320        assert_eq!(non_digit_cmp("~", "A"), Ordering::Less);
1321        assert_eq!(non_digit_cmp("~", "!"), Ordering::Less);
1322
1323        // Test special character ordering
1324        assert_eq!(non_digit_cmp("!", "@"), Ordering::Less);
1325        assert_eq!(non_digit_cmp("@", "A"), Ordering::Greater);
1326        assert_eq!(non_digit_cmp("Z", "["), Ordering::Less);
1327
1328        // Test empty strings
1329        assert_eq!(non_digit_cmp("", ""), Ordering::Equal);
1330        assert_eq!(non_digit_cmp("", "a"), Ordering::Less);
1331        assert_eq!(non_digit_cmp("a", ""), Ordering::Greater);
1332    }
1333
1334    #[test]
1335    fn test_bump_nmu() {
1336        // Native package: append +nmu1, then bump it.
1337        assert_eq!(
1338            "1.0+nmu1".parse::<Version>().unwrap(),
1339            "1.0".parse::<Version>().unwrap().bump_nmu()
1340        );
1341        assert_eq!(
1342            "1.0+nmu2".parse::<Version>().unwrap(),
1343            "1.0+nmu1".parse::<Version>().unwrap().bump_nmu()
1344        );
1345
1346        // Non-native package: first NMU appends `.1` to the revision.
1347        assert_eq!(
1348            "1.0-1.1".parse::<Version>().unwrap(),
1349            "1.0-1".parse::<Version>().unwrap().bump_nmu()
1350        );
1351        assert_eq!(
1352            "1.0-2.1".parse::<Version>().unwrap(),
1353            "1.0-2".parse::<Version>().unwrap().bump_nmu()
1354        );
1355
1356        // Further NMU bumps the trailing component of the revision.
1357        assert_eq!(
1358            "1.0-1.2".parse::<Version>().unwrap(),
1359            "1.0-1.1".parse::<Version>().unwrap().bump_nmu()
1360        );
1361        assert_eq!(
1362            "1.0-2.2".parse::<Version>().unwrap(),
1363            "1.0-2.1".parse::<Version>().unwrap().bump_nmu()
1364        );
1365
1366        // Epoch is preserved.
1367        assert_eq!(
1368            "2:1.0-1.1".parse::<Version>().unwrap(),
1369            "2:1.0-1".parse::<Version>().unwrap().bump_nmu()
1370        );
1371
1372        // Revisions that don't follow the integer(.integer) shape fall back
1373        // to a +nmuN suffix on the Debian revision.
1374        assert_eq!(
1375            "1.0-1ubuntu1+nmu1".parse::<Version>().unwrap(),
1376            "1.0-1ubuntu1".parse::<Version>().unwrap().bump_nmu()
1377        );
1378        assert_eq!(
1379            "1.0-1ubuntu1+nmu2".parse::<Version>().unwrap(),
1380            "1.0-1ubuntu1+nmu1".parse::<Version>().unwrap().bump_nmu()
1381        );
1382
1383        // The result of an NMU bump is recognised as an NMU, regardless of
1384        // which convention was used.
1385        assert!("1.0".parse::<Version>().unwrap().bump_nmu().is_nmu());
1386        assert!("1.0-1".parse::<Version>().unwrap().bump_nmu().is_nmu());
1387        assert!("1.0-1.1".parse::<Version>().unwrap().bump_nmu().is_nmu());
1388        assert!("1.0-1ubuntu1"
1389            .parse::<Version>()
1390            .unwrap()
1391            .bump_nmu()
1392            .is_nmu());
1393    }
1394
1395    #[test]
1396    fn test_parse_lenient() {
1397        // Test parsing version with underscores (violates Debian Policy but exists in the wild)
1398        let v = Version::parse_lenient("2.0.37+cvs.JCW_PRE2_2037-1").unwrap();
1399        assert_eq!(v.epoch, None);
1400        assert_eq!(v.upstream_version, "2.0.37+cvs.JCW_PRE2_2037");
1401        assert_eq!(v.debian_revision, Some("1".to_string()));
1402
1403        // Test with epoch and underscores
1404        let v2 = Version::parse_lenient("1:3.5_beta-2").unwrap();
1405        assert_eq!(v2.epoch, Some(1));
1406        assert_eq!(v2.upstream_version, "3.5_beta");
1407        assert_eq!(v2.debian_revision, Some("2".to_string()));
1408
1409        // Test with underscores in debian revision
1410        let v3 = Version::parse_lenient("1.0-ubuntu_1").unwrap();
1411        assert_eq!(v3.epoch, None);
1412        assert_eq!(v3.upstream_version, "1.0");
1413        assert_eq!(v3.debian_revision, Some("ubuntu_1".to_string()));
1414
1415        // Test that standard versions still work with lenient parsing
1416        let v4 = Version::parse_lenient("1.0-1").unwrap();
1417        assert_eq!(v4.epoch, None);
1418        assert_eq!(v4.upstream_version, "1.0");
1419        assert_eq!(v4.debian_revision, Some("1".to_string()));
1420    }
1421
1422    #[test]
1423    fn test_parse_strict_rejects_underscores() {
1424        // Verify that the strict parser (FromStr) still rejects underscores
1425        assert!("2.0.37+cvs.JCW_PRE2_2037-1".parse::<Version>().is_err());
1426        assert!("3.5_beta-1".parse::<Version>().is_err());
1427        assert!("1.0-ubuntu_1".parse::<Version>().is_err());
1428
1429        // But standard versions should still parse fine
1430        assert!("1.0-1".parse::<Version>().is_ok());
1431        assert!("1:2.0+really1.0-1".parse::<Version>().is_ok());
1432    }
1433}