debian_control/lossless/
changes.rs

1//! Changes files
2
3use rowan::ast::AstNode;
4
5/// Changes file
6#[derive(Debug, Clone, PartialEq, Eq)]
7pub struct Changes(deb822_lossless::Paragraph);
8
9/// Errors that can occur when parsing a Changes file.
10#[derive(Debug)]
11pub enum ParseError {
12    /// An error occurred while parsing a Deb822 file.
13    Deb822(deb822_lossless::Error),
14
15    /// No paragraphs were found in the file.
16    NoParagraphs,
17
18    /// Multiple paragraphs were found in the file.
19    MultipleParagraphs,
20}
21
22impl std::fmt::Display for ParseError {
23    fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
24        match self {
25            Self::Deb822(e) => write!(f, "{}", e),
26            Self::NoParagraphs => write!(f, "no paragraphs found"),
27            Self::MultipleParagraphs => write!(f, "multiple paragraphs found"),
28        }
29    }
30}
31
32impl std::error::Error for ParseError {}
33
34impl From<deb822_lossless::Error> for ParseError {
35    fn from(e: deb822_lossless::Error) -> Self {
36        Self::Deb822(e)
37    }
38}
39
40/// A file in a source package.
41#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)]
42pub struct File {
43    /// MD5 checksum of the file.
44    pub md5sum: String,
45    /// Size of the file in bytes.
46    pub size: usize,
47    /// Section the file belongs to.
48    pub section: String,
49    /// Priority of the file.
50    pub priority: crate::Priority,
51    /// Filename of the file.
52    pub filename: String,
53}
54
55impl std::fmt::Display for File {
56    fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
57        write!(
58            f,
59            "{} {} {} {} {}",
60            self.md5sum, self.size, self.section, self.priority, self.filename
61        )
62    }
63}
64
65impl std::str::FromStr for File {
66    type Err = ();
67
68    fn from_str(s: &str) -> Result<Self, Self::Err> {
69        let mut parts = s.split_whitespace();
70        let md5sum = parts.next().ok_or(())?;
71        let size = parts.next().ok_or(())?.parse().map_err(|_| ())?;
72        let section = parts.next().ok_or(())?.to_string();
73        let priority = parts.next().ok_or(())?.parse().map_err(|_| ())?;
74        let filename = parts.next().ok_or(())?.to_string();
75        Ok(Self {
76            md5sum: md5sum.to_string(),
77            size,
78            section,
79            priority,
80            filename,
81        })
82    }
83}
84
85impl Changes {
86    /// Parse changes text, returning a Parse result
87    ///
88    /// Note: This expects a single paragraph, not a full deb822 document
89    pub fn parse(text: &str) -> deb822_lossless::Parse<Changes> {
90        let deb822_parse = deb822_lossless::Deb822::parse(text);
91        let green = deb822_parse.green().clone();
92        let mut errors = deb822_parse.errors().to_vec();
93
94        // Check if there's exactly one paragraph
95        if errors.is_empty() {
96            let deb822 = deb822_parse.tree();
97            let paragraph_count = deb822.paragraphs().count();
98            if paragraph_count == 0 {
99                errors.push("No paragraphs found".to_string());
100            } else if paragraph_count > 1 {
101                errors.push("Multiple paragraphs found, expected one".to_string());
102            }
103        }
104
105        deb822_lossless::Parse::new(green, errors)
106    }
107
108    /// Returns the format of the Changes file.
109    pub fn format(&self) -> Option<String> {
110        self.0.get("Format").map(|s| s.to_string())
111    }
112
113    /// Set the format of the Changes file.
114    pub fn set_format(&mut self, value: &str) {
115        self.0.set("Format", value);
116    }
117
118    /// Returns the name of the source package.
119    pub fn source(&self) -> Option<String> {
120        self.0.get("Source").map(|s| s.to_string())
121    }
122
123    /// Returns the list of binary packages generated by the source package.
124    pub fn binary(&self) -> Option<Vec<String>> {
125        self.0
126            .get("Binary")
127            .map(|s| s.split_whitespace().map(|s| s.to_string()).collect())
128    }
129
130    /// Returns the architecture the source package is intended for.
131    pub fn architecture(&self) -> Option<Vec<String>> {
132        self.0
133            .get("Architecture")
134            .map(|s| s.split_whitespace().map(|s| s.to_string()).collect())
135    }
136
137    /// Returns the version of the source package.
138    pub fn version(&self) -> Option<debversion::Version> {
139        self.0.get("Version").map(|s| s.parse().unwrap())
140    }
141
142    /// Returns the distribution the source package is intended for.
143    pub fn distribution(&self) -> Option<String> {
144        self.0.get("Distribution").map(|s| s.to_string())
145    }
146
147    /// Returns the urgency of the source package.
148    pub fn urgency(&self) -> Option<crate::fields::Urgency> {
149        self.0.get("Urgency").map(|s| s.parse().unwrap())
150    }
151
152    /// Returns the name and email address of the person who maintains the package.
153    pub fn maintainer(&self) -> Option<String> {
154        self.0.get("Maintainer").map(|s| s.to_string())
155    }
156
157    /// Returns the name and email address of the person who uploaded the package.
158    pub fn changed_by(&self) -> Option<String> {
159        self.0.get("Changed-By").map(|s| s.to_string())
160    }
161
162    /// Returns the description of the source package.
163    pub fn description(&self) -> Option<String> {
164        self.0.get("Description").map(|s| s.to_string())
165    }
166
167    /// Returns the SHA-1 checksums of the files in the source package.
168    pub fn checksums_sha1(&self) -> Option<Vec<crate::fields::Sha1Checksum>> {
169        self.0
170            .get("Checksums-Sha1")
171            .map(|s| s.lines().map(|line| line.parse().unwrap()).collect())
172    }
173
174    /// Returns the SHA-256 checksums of the files in the source package.
175    pub fn checksums_sha256(&self) -> Option<Vec<crate::fields::Sha256Checksum>> {
176        self.0
177            .get("Checksums-Sha256")
178            .map(|s| s.lines().map(|line| line.parse().unwrap()).collect())
179    }
180
181    /// Returns the list of files in the source package.
182    pub fn files(&self) -> Option<Vec<File>> {
183        self.0
184            .get("Files")
185            .map(|s| s.lines().map(|line| line.parse().unwrap()).collect())
186    }
187
188    /// Returns the path to the pool directory for the source package.
189    pub fn get_pool_path(&self) -> Option<String> {
190        let files = self.files()?;
191
192        let section = &files.first().unwrap().section;
193
194        let section = if let Some((section, _subsection)) = section.split_once('/') {
195            section
196        } else {
197            "main"
198        };
199
200        let source = self.source()?;
201
202        let subdir = if source.starts_with("lib") {
203            "lib".to_string()
204        } else {
205            source[..1].to_lowercase()
206        };
207
208        Some(format!("pool/{}/{}/{}", section, subdir, source))
209    }
210
211    /// Create a new Changes file.
212    pub fn new() -> Self {
213        let mut slf = Self(deb822_lossless::Paragraph::new());
214        slf.set_format("1.8");
215        slf
216    }
217
218    /// Read a Changes file from a file.
219    pub fn from_file<P: AsRef<std::path::Path>>(path: P) -> Result<Self, ParseError> {
220        let deb822 = deb822_lossless::Deb822::from_file(path)?;
221        let mut paras = deb822.paragraphs();
222        let para = match paras.next() {
223            Some(para) => para,
224            None => return Err(ParseError::NoParagraphs),
225        };
226        if paras.next().is_some() {
227            return Err(ParseError::MultipleParagraphs);
228        }
229        Ok(Self(para))
230    }
231
232    /// Read a Changes file from a file, allowing syntax errors.
233    pub fn from_file_relaxed<P: AsRef<std::path::Path>>(
234        path: P,
235    ) -> Result<(Self, Vec<String>), std::io::Error> {
236        let (mut deb822, mut errors) = deb822_lossless::Deb822::from_file_relaxed(path)?;
237        let mut paras = deb822.paragraphs();
238        let para = match paras.next() {
239            Some(para) => para,
240            None => deb822.add_paragraph(),
241        };
242        if paras.next().is_some() {
243            errors.push("multiple paragraphs found".to_string());
244        }
245        Ok((Self(para), errors))
246    }
247
248    /// Read a Changes file from a reader.
249    pub fn read<R: std::io::Read>(mut r: R) -> Result<Self, ParseError> {
250        let deb822 = deb822_lossless::Deb822::read(&mut r)?;
251        let mut paras = deb822.paragraphs();
252        let para = match paras.next() {
253            Some(para) => para,
254            None => return Err(ParseError::NoParagraphs),
255        };
256        if paras.next().is_some() {
257            return Err(ParseError::MultipleParagraphs);
258        }
259        Ok(Self(para))
260    }
261
262    /// Read a Changes file from a reader, allowing syntax errors.
263    pub fn read_relaxed<R: std::io::Read>(
264        mut r: R,
265    ) -> Result<(Self, Vec<String>), deb822_lossless::Error> {
266        let (mut deb822, mut errors) = deb822_lossless::Deb822::read_relaxed(&mut r)?;
267        let mut paras = deb822.paragraphs();
268        let para = match paras.next() {
269            Some(para) => para,
270            None => deb822.add_paragraph(),
271        };
272        if paras.next().is_some() {
273            errors.push("multiple paragraphs found".to_string());
274        }
275        Ok((Self(para), errors))
276    }
277}
278
279impl Default for Changes {
280    fn default() -> Self {
281        Self::new()
282    }
283}
284
285#[cfg(feature = "python-debian")]
286impl<'py> pyo3::IntoPyObject<'py> for Changes {
287    type Target = pyo3::PyAny;
288    type Output = pyo3::Bound<'py, Self::Target>;
289    type Error = pyo3::PyErr;
290
291    fn into_pyobject(self, py: pyo3::Python<'py>) -> Result<Self::Output, Self::Error> {
292        self.0.into_pyobject(py)
293    }
294}
295
296#[cfg(feature = "python-debian")]
297impl<'a, 'py> pyo3::IntoPyObject<'py> for &'a Changes {
298    type Target = pyo3::PyAny;
299    type Output = pyo3::Bound<'py, Self::Target>;
300    type Error = pyo3::PyErr;
301
302    fn into_pyobject(self, py: pyo3::Python<'py>) -> Result<Self::Output, Self::Error> {
303        (&self.0).into_pyobject(py)
304    }
305}
306
307#[cfg(feature = "python-debian")]
308impl pyo3::FromPyObject<'_> for Changes {
309    fn extract_bound(ob: &pyo3::Bound<pyo3::PyAny>) -> pyo3::PyResult<Self> {
310        use pyo3::prelude::*;
311        Ok(Changes(ob.extract()?))
312    }
313}
314
315impl AstNode for Changes {
316    type Language = deb822_lossless::Lang;
317
318    fn can_cast(kind: <Self::Language as rowan::Language>::Kind) -> bool {
319        deb822_lossless::Paragraph::can_cast(kind) || deb822_lossless::Deb822::can_cast(kind)
320    }
321
322    fn cast(syntax: rowan::SyntaxNode<Self::Language>) -> Option<Self> {
323        if let Some(para) = deb822_lossless::Paragraph::cast(syntax.clone()) {
324            Some(Changes(para))
325        } else if let Some(deb822) = deb822_lossless::Deb822::cast(syntax) {
326            deb822.paragraphs().next().map(Changes)
327        } else {
328            None
329        }
330    }
331
332    fn syntax(&self) -> &rowan::SyntaxNode<Self::Language> {
333        self.0.syntax()
334    }
335}
336
337#[cfg(test)]
338mod tests {
339    #[test]
340    fn test_new() {
341        let changes = super::Changes::new();
342        assert_eq!(changes.format(), Some("1.8".to_string()));
343    }
344
345    #[test]
346    fn test_parse() {
347        let changes = r#"Format: 1.8
348Date: Fri, 08 Sep 2023 18:23:59 +0100
349Source: buildlog-consultant
350Binary: python3-buildlog-consultant
351Architecture: all
352Version: 0.0.34-1
353Distribution: unstable
354Urgency: medium
355Maintainer: Jelmer Vernooij <jelmer@debian.org>
356Changed-By: Jelmer Vernooij <jelmer@debian.org>
357Description:
358 python3-buildlog-consultant - build log parser and analyser
359Changes:
360 buildlog-consultant (0.0.34-1) UNRELEASED; urgency=medium
361 .
362   * New upstream release.
363   * Update standards version to 4.6.2, no changes needed.
364Checksums-Sha1:
365 f1657e628254428ad74542e82c253a181894e8d0 17153 buildlog-consultant_0.0.34-1_amd64.buildinfo
366 b44493c05d014bcd59180942d0125b20ddf45d03 2550812 python3-buildlog-consultant_0.0.34-1_all.deb
367Checksums-Sha256:
368 342a5782bf6a4f282d9002f726d2cac9c689c7e0fa7f61a1b0ecbf4da7916bdb 17153 buildlog-consultant_0.0.34-1_amd64.buildinfo
369 7f7e5df81ee23fbbe89015edb37e04f4bb40672fa6e9b1afd4fd698e57db78fd 2550812 python3-buildlog-consultant_0.0.34-1_all.deb
370Files:
371 aa83112b0f8774a573bcf0b7b5cc12cc 17153 python optional buildlog-consultant_0.0.34-1_amd64.buildinfo
372 a55858b90fe0ca728c89c1a1132b45c5 2550812 python optional python3-buildlog-consultant_0.0.34-1_all.deb
373"#;
374        let changes = super::Changes::read(changes.as_bytes()).unwrap();
375        assert_eq!(changes.format(), Some("1.8".to_string()));
376        assert_eq!(changes.source(), Some("buildlog-consultant".to_string()));
377        assert_eq!(
378            changes.binary(),
379            Some(vec!["python3-buildlog-consultant".to_string()])
380        );
381        assert_eq!(changes.architecture(), Some(vec!["all".to_string()]));
382        assert_eq!(changes.version(), Some("0.0.34-1".parse().unwrap()));
383        assert_eq!(changes.distribution(), Some("unstable".to_string()));
384        assert_eq!(changes.urgency(), Some(crate::fields::Urgency::Medium));
385        assert_eq!(
386            changes.maintainer(),
387            Some("Jelmer Vernooij <jelmer@debian.org>".to_string())
388        );
389        assert_eq!(
390            changes.changed_by(),
391            Some("Jelmer Vernooij <jelmer@debian.org>".to_string())
392        );
393        assert_eq!(
394            changes.description(),
395            Some("python3-buildlog-consultant - build log parser and analyser".to_string())
396        );
397        assert_eq!(
398            changes.checksums_sha1(),
399            Some(vec![
400                "f1657e628254428ad74542e82c253a181894e8d0 17153 buildlog-consultant_0.0.34-1_amd64.buildinfo".parse().unwrap(),
401                "b44493c05d014bcd59180942d0125b20ddf45d03 2550812 python3-buildlog-consultant_0.0.34-1_all.deb".parse().unwrap()
402            ])
403        );
404        assert_eq!(
405            changes.checksums_sha256(),
406            Some(vec![
407                "342a5782bf6a4f282d9002f726d2cac9c689c7e0fa7f61a1b0ecbf4da7916bdb 17153 buildlog-consultant_0.0.34-1_amd64.buildinfo"
408                    .parse()
409                    .unwrap(),
410                "7f7e5df81ee23fbbe89015edb37e04f4bb40672fa6e9b1afd4fd698e57db78fd 2550812 python3-buildlog-consultant_0.0.34-1_all.deb"
411                    .parse()
412                    .unwrap()
413            ])
414        );
415        assert_eq!(
416            changes.files(),
417            Some(vec![
418                "aa83112b0f8774a573bcf0b7b5cc12cc 17153 python optional buildlog-consultant_0.0.34-1_amd64.buildinfo".parse().unwrap(),
419                "a55858b90fe0ca728c89c1a1132b45c5 2550812 python optional python3-buildlog-consultant_0.0.34-1_all.deb".parse().unwrap()
420            ])
421        );
422
423        assert_eq!(
424            changes.get_pool_path(),
425            Some("pool/main/b/buildlog-consultant".to_string())
426        );
427    }
428}