1#![deny(missing_docs)]
2#[cfg(feature = "lossless")]
42pub mod lossless;
43pub mod lossy;
44pub use lossy::Copyright;
45
46pub const CURRENT_FORMAT: &str =
48 "https://www.debian.org/doc/packaging-manuals/copyright-format/1.0/";
49
50pub const KNOWN_FORMATS: &[&str] = &[CURRENT_FORMAT];
52
53mod glob;
54
55fn decode_field_text(text: &str) -> String {
70 text.lines()
71 .map(|line| {
72 if line == "." {
73 ""
75 } else {
76 line
77 }
78 })
79 .collect::<Vec<_>>()
80 .join("\n")
81}
82
83fn encode_field_text(text: &str) -> String {
96 text.lines()
97 .map(|line| {
98 if line.is_empty() {
99 "."
101 } else {
102 line
103 }
104 })
105 .collect::<Vec<_>>()
106 .join("\n")
107}
108
109#[derive(Clone, PartialEq, Eq, Debug)]
111pub enum License {
112 Name(String),
114
115 Text(String),
117
118 Named(String, String),
120}
121
122impl License {
123 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 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
169pub fn pattern_depth(pattern: &str) -> usize {
171 pattern.matches('/').count()
172}
173
174pub fn is_debian_pattern(pattern: &str) -> bool {
176 let trimmed = pattern.trim();
177 trimmed.starts_with("debian/") || trimmed == "debian/*"
178}
179
180pub 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 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 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 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 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 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 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 assert_eq!(pattern_sort_key("*", 0), (0, 0));
294 assert_eq!(pattern_sort_key(" * ", 0), (0, 0));
295
296 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 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 assert!(pattern_sort_key("*", 0) < pattern_sort_key("src/*", 1));
310 assert!(pattern_sort_key("*", 0) < pattern_sort_key("debian/*", 1));
311
312 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 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 assert!(pattern_sort_key("src/*", 1) < pattern_sort_key("debian/*", 1));
322 }
323}