Skip to main content

debian_copyright/
lib.rs

1#![deny(missing_docs)]
2//! A library for parsing and manipulating debian/copyright files that
3//! use the DEP-5 format.
4//!
5//! # Examples
6//!
7//! ```rust
8//!
9//! use debian_copyright::Copyright;
10//! use std::path::Path;
11//!
12//! let text = r#"Format: https://www.debian.org/doc/packaging-manuals/copyright-format/1.0/
13//! Upstream-Author: John Doe <john@example>
14//! Upstream-Name: example
15//! Source: https://example.com/example
16//!
17//! Files: *
18//! License: GPL-3+
19//! Copyright: 2019 John Doe
20//!
21//! Files: debian/*
22//! License: GPL-3+
23//! Copyright: 2019 Jane Packager
24//!
25//! License: GPL-3+
26//!  This program is free software: you can redistribute it and/or modify
27//!  it under the terms of the GNU General Public License as published by
28//!  the Free Software Foundation, either version 3 of the License, or
29//!  (at your option) any later version.
30//! "#;
31//!
32//! let c = text.parse::<Copyright>().unwrap();
33//! let license = c.find_license_for_file(Path::new("debian/foo")).unwrap();
34//! assert_eq!(license.name(), Some("GPL-3+"));
35//! ```
36//!
37//! See the ``lossless`` module (behind the ``lossless`` feature) for a more forgiving parser that
38//! allows partial parsing, parsing files with errors and unknown fields and editing while
39//! preserving formatting.
40
41#[cfg(feature = "lossless")]
42pub mod lossless;
43pub mod lossy;
44pub use lossy::Copyright;
45
46/// The current version of the DEP-5 format.
47pub const CURRENT_FORMAT: &str =
48    "https://www.debian.org/doc/packaging-manuals/copyright-format/1.0/";
49
50/// The known versions of the DEP-5 format.
51pub const KNOWN_FORMATS: &[&str] = &[CURRENT_FORMAT];
52
53mod glob;
54
55/// Decode deb822 paragraph markers in a multi-line field value.
56///
57/// According to Debian policy, blank lines in multi-line field values are
58/// represented as lines containing only "." (a single period). The deb822
59/// parser already strips the leading indentation whitespace from continuation lines,
60/// so we only need to decode the period markers back to blank lines.
61///
62/// # Arguments
63///
64/// * `text` - The raw field value text from deb822 parser with indentation already stripped
65///
66/// # Returns
67///
68/// The decoded text with blank lines restored
69fn decode_field_text(text: &str) -> String {
70    text.lines()
71        .map(|line| {
72            if line == "." {
73                // Paragraph marker representing a blank line
74                ""
75            } else {
76                line
77            }
78        })
79        .collect::<Vec<_>>()
80        .join("\n")
81}
82
83/// Encode blank lines in a field value to deb822 paragraph markers.
84///
85/// According to Debian policy, blank lines in multi-line field values must be
86/// represented as lines containing only "." (a single period).
87///
88/// # Arguments
89///
90/// * `text` - The decoded text with normal blank lines
91///
92/// # Returns
93///
94/// The encoded text with blank lines replaced by "."
95fn encode_field_text(text: &str) -> String {
96    text.lines()
97        .map(|line| {
98            if line.is_empty() {
99                // Blank line must be encoded as period marker
100                "."
101            } else {
102                line
103            }
104        })
105        .collect::<Vec<_>>()
106        .join("\n")
107}
108
109/// A license, which can be just a name, a text or a named license.
110#[derive(Clone, PartialEq, Eq, Debug)]
111pub enum License {
112    /// A license with just a name.
113    Name(String),
114
115    /// A license with just a text.
116    Text(String),
117
118    /// A license with a name and a text.
119    Named(String, String),
120}
121
122impl License {
123    /// Returns the name of the license, if any.
124    pub fn name(&self) -> Option<&str> {
125        match self {
126            License::Name(name) => Some(name),
127            License::Text(_) => None,
128            License::Named(name, _) => Some(name),
129        }
130    }
131
132    /// Returns the text of the license, if any.
133    pub fn text(&self) -> Option<&str> {
134        match self {
135            License::Name(_) => None,
136            License::Text(text) => Some(text),
137            License::Named(_, text) => Some(text),
138        }
139    }
140}
141
142impl std::str::FromStr for License {
143    type Err = String;
144
145    fn from_str(text: &str) -> Result<Self, Self::Err> {
146        if let Some((name, rest)) = text.split_once('\n') {
147            let decoded_text = decode_field_text(rest);
148            if name.is_empty() {
149                Ok(License::Text(decoded_text))
150            } else {
151                Ok(License::Named(name.to_string(), decoded_text))
152            }
153        } else {
154            Ok(License::Name(text.to_string()))
155        }
156    }
157}
158
159impl std::fmt::Display for License {
160    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
161        match self {
162            License::Name(name) => f.write_str(name),
163            License::Text(text) => write!(f, "\n{}", encode_field_text(text)),
164            License::Named(name, text) => write!(f, "{}\n{}", name, encode_field_text(text)),
165        }
166    }
167}
168
169/// Calculate the depth of a Files pattern by counting '/' characters.
170pub fn pattern_depth(pattern: &str) -> usize {
171    pattern.matches('/').count()
172}
173
174/// Check if a pattern is a debian/* pattern (should be sorted last by convention).
175pub fn is_debian_pattern(pattern: &str) -> bool {
176    let trimmed = pattern.trim();
177    trimmed.starts_with("debian/") || trimmed == "debian/*"
178}
179
180/// Calculate a sort key for a Files pattern.
181///
182/// Returns `(priority, depth)` where:
183/// - priority 0: `*` (always first)
184/// - priority 1: normal patterns (sorted by depth)
185/// - priority 2: debian/* patterns (always last, then by depth)
186///
187/// This follows the Debian convention that the `*` wildcard should be first,
188/// and `debian/*` patterns should be last in debian/copyright Files paragraphs.
189pub fn pattern_sort_key(pattern: &str, depth: usize) -> (u8, usize) {
190    let trimmed = pattern.trim();
191
192    if trimmed == "*" {
193        (0, 0)
194    } else if is_debian_pattern(pattern) {
195        (2, depth)
196    } else {
197        (1, depth)
198    }
199}
200
201#[cfg(test)]
202mod tests {
203    use super::*;
204
205    #[test]
206    fn test_decode_field_text() {
207        // Test basic decoding of period markers
208        let input = "line 1\n.\nline 3";
209        let output = decode_field_text(input);
210        assert_eq!(output, "line 1\n\nline 3");
211    }
212
213    #[test]
214    fn test_decode_field_text_no_markers() {
215        // Test text without markers remains unchanged
216        let input = "line 1\nline 2\nline 3";
217        let output = decode_field_text(input);
218        assert_eq!(output, input);
219    }
220
221    #[test]
222    fn test_license_from_str_with_paragraph_markers() {
223        // Test that License::from_str decodes paragraph markers
224        let input = "GPL-3+\nThis is line 1\n.\nThis is line 3";
225        let license: License = input.parse().unwrap();
226
227        match license {
228            License::Named(name, text) => {
229                assert_eq!(name, "GPL-3+");
230                assert_eq!(text, "This is line 1\n\nThis is line 3");
231                assert!(!text.contains("\n.\n"));
232            }
233            _ => panic!("Expected Named license"),
234        }
235    }
236
237    #[test]
238    fn test_encode_field_text() {
239        // Test basic encoding of blank lines
240        let input = "line 1\n\nline 3";
241        let output = encode_field_text(input);
242        assert_eq!(output, "line 1\n.\nline 3");
243    }
244
245    #[test]
246    fn test_encode_decode_round_trip() {
247        // Test that encoding and decoding are inverse operations
248        let original = "First paragraph\n\nSecond paragraph\n\nThird paragraph";
249        let encoded = encode_field_text(original);
250        let decoded = decode_field_text(&encoded);
251        assert_eq!(
252            decoded, original,
253            "Round-trip encoding/decoding should preserve text"
254        );
255    }
256
257    #[test]
258    fn test_license_display_encodes_blank_lines() {
259        // Test that License::Display encodes blank lines
260        let license = License::Named("MIT".to_string(), "Line 1\n\nLine 2".to_string());
261        let displayed = license.to_string();
262        assert_eq!(displayed, "MIT\nLine 1\n.\nLine 2");
263        assert!(displayed.contains("\n.\n"), "Should contain period marker");
264        assert_eq!(
265            displayed.matches("\n\n").count(),
266            0,
267            "Should not contain literal blank lines"
268        );
269    }
270
271    #[test]
272    fn test_pattern_depth() {
273        assert_eq!(pattern_depth("*"), 0);
274        assert_eq!(pattern_depth("src/*"), 1);
275        assert_eq!(pattern_depth("src/foo/*"), 2);
276        assert_eq!(pattern_depth("a/b/c/d/*"), 4);
277        assert_eq!(pattern_depth("debian/*"), 1);
278    }
279
280    #[test]
281    fn test_is_debian_pattern() {
282        assert!(is_debian_pattern("debian/*"));
283        assert!(is_debian_pattern("debian/patches/*"));
284        assert!(is_debian_pattern(" debian/* "));
285        assert!(!is_debian_pattern("*"));
286        assert!(!is_debian_pattern("src/*"));
287        assert!(!is_debian_pattern("src/debian/*"));
288    }
289
290    #[test]
291    fn test_pattern_sort_key() {
292        // Test wildcard pattern (priority 0)
293        assert_eq!(pattern_sort_key("*", 0), (0, 0));
294        assert_eq!(pattern_sort_key(" * ", 0), (0, 0));
295
296        // Test normal patterns (priority 1)
297        assert_eq!(pattern_sort_key("src/*", 1), (1, 1));
298        assert_eq!(pattern_sort_key("src/foo/*", 2), (1, 2));
299        assert_eq!(pattern_sort_key("tests/*", 1), (1, 1));
300
301        // Test debian patterns (priority 2)
302        assert_eq!(pattern_sort_key("debian/*", 1), (2, 1));
303        assert_eq!(pattern_sort_key("debian/patches/*", 2), (2, 2));
304    }
305
306    #[test]
307    fn test_pattern_sort_key_ordering() {
308        // Wildcard comes first
309        assert!(pattern_sort_key("*", 0) < pattern_sort_key("src/*", 1));
310        assert!(pattern_sort_key("*", 0) < pattern_sort_key("debian/*", 1));
311
312        // Normal patterns come before debian patterns
313        assert!(pattern_sort_key("src/*", 1) < pattern_sort_key("debian/*", 1));
314        assert!(pattern_sort_key("tests/*", 1) < pattern_sort_key("debian/*", 1));
315
316        // Within same priority, shallower comes before deeper
317        assert!(pattern_sort_key("src/*", 1) < pattern_sort_key("src/foo/*", 2));
318        assert!(pattern_sort_key("debian/*", 1) < pattern_sort_key("debian/patches/*", 2));
319
320        // Debian patterns come last even with same depth as normal patterns
321        assert!(pattern_sort_key("src/*", 1) < pattern_sort_key("debian/*", 1));
322    }
323}