Skip to main content

debian_copyright/
lossless.rs

1//! A library for parsing and manipulating debian/copyright files that
2//! use the DEP-5 format.
3//!
4//! This library is intended to be used for manipulating debian/copyright
5//!
6//! # Examples
7//!
8//! ```rust
9//!
10//! use debian_copyright::lossless::Copyright;
11//! use std::path::Path;
12//!
13//! let text = r#"Format: https://www.debian.org/doc/packaging-manuals/copyright-format/1.0/
14//! Upstream-Author: John Doe <john@example>
15//! Upstream-Name: example
16//! Source: https://example.com/example
17//!
18//! Files: *
19//! License: GPL-3+
20//! Copyright: 2019 John Doe
21//!
22//! Files: debian/*
23//! License: GPL-3+
24//! Copyright: 2019 Jane Packager
25//!
26//! License: GPL-3+
27//!  This program is free software: you can redistribute it and/or modify
28//!  it under the terms of the GNU General Public License as published by
29//!  the Free Software Foundation, either version 3 of the License, or
30//!  (at your option) any later version.
31//! "#;
32//!
33//! let c = text.parse::<Copyright>().unwrap();
34//! let license = c.find_license_for_file(Path::new("debian/foo")).unwrap();
35//! assert_eq!(license.name(), Some("GPL-3+"));
36//! ```
37
38use crate::{License, CURRENT_FORMAT, KNOWN_FORMATS};
39use deb822_lossless::IndentPattern;
40use deb822_lossless::{Deb822, Paragraph, TextRange};
41use std::path::Path;
42
43/// Decode deb822 paragraph markers in a multi-line field value.
44///
45/// According to Debian policy, blank lines in multi-line field values are
46/// represented as lines containing only "." (a single period). The deb822-lossless
47/// parser already strips the leading indentation whitespace from continuation lines,
48/// so we only need to decode the period markers back to blank lines.
49///
50/// # Arguments
51///
52/// * `text` - The raw field value text from deb822-lossless with indentation already stripped
53///
54/// # Returns
55///
56/// The decoded text with blank lines restored
57fn decode_field_text(text: &str) -> String {
58    text.lines()
59        .map(|line| {
60            if line == "." {
61                // Paragraph marker representing a blank line
62                ""
63            } else {
64                line
65            }
66        })
67        .collect::<Vec<_>>()
68        .join("\n")
69}
70
71/// Encode blank lines in a field value to deb822 paragraph markers.
72///
73/// According to Debian policy, blank lines in multi-line field values must be
74/// represented as lines containing only "." (a single period). The deb822-lossless
75/// library will reject values with actual blank lines, so we must encode them first.
76///
77/// # Arguments
78///
79/// * `text` - The decoded text with normal blank lines
80///
81/// # Returns
82///
83/// The encoded text with blank lines replaced by "."
84fn encode_field_text(text: &str) -> String {
85    text.lines()
86        .map(|line| {
87            if line.is_empty() {
88                // Blank line must be encoded as period marker
89                "."
90            } else {
91                line
92            }
93        })
94        .collect::<Vec<_>>()
95        .join("\n")
96}
97
98/// Field order for header paragraphs according to DEP-5 specification
99const HEADER_FIELD_ORDER: &[&str] = &[
100    "Format",
101    "Upstream-Name",
102    "Upstream-Contact",
103    "Source",
104    "Disclaimer",
105    "Comment",
106    "License",
107    "Copyright",
108];
109
110/// Field order for Files paragraphs according to DEP-5 specification
111const FILES_FIELD_ORDER: &[&str] = &["Files", "Copyright", "License", "Comment"];
112
113/// Field order for standalone License paragraphs according to DEP-5 specification
114const LICENSE_FIELD_ORDER: &[&str] = &["License", "Comment"];
115
116/// Default separator for files in Files field
117const FILES_SEPARATOR: &str = " ";
118
119/// A copyright file
120#[derive(Debug, Clone, PartialEq)]
121pub struct Copyright(Deb822);
122
123impl Copyright {
124    /// Create a new copyright file, with the current format
125    pub fn new() -> Self {
126        let mut deb822 = Deb822::new();
127        let mut header = deb822.add_paragraph();
128        header.set("Format", CURRENT_FORMAT);
129        Copyright(deb822)
130    }
131
132    /// Create a new empty copyright file
133    ///
134    /// The difference with `new` is that this does not add the `Format` field.
135    pub fn empty() -> Self {
136        Self(Deb822::new())
137    }
138
139    /// Return the underlying Deb822 object
140    pub fn as_deb822(&self) -> &Deb822 {
141        &self.0
142    }
143
144    /// Return the header paragraph
145    pub fn header(&self) -> Option<Header> {
146        self.0.paragraphs().next().map(Header)
147    }
148
149    /// Iterate over all files paragraphs
150    pub fn iter_files(&self) -> impl Iterator<Item = FilesParagraph> {
151        self.0
152            .paragraphs()
153            .filter(|x| x.contains_key("Files"))
154            .map(FilesParagraph)
155    }
156
157    /// Iter over all license paragraphs
158    pub fn iter_licenses(&self) -> impl Iterator<Item = LicenseParagraph> {
159        self.0
160            .paragraphs()
161            .filter(|x| {
162                !x.contains_key("Files") && !x.contains_key("Format") && x.contains_key("License")
163            })
164            .map(LicenseParagraph)
165    }
166
167    /// Return the header paragraph if it intersects with the given text range
168    ///
169    /// # Arguments
170    /// * `range` - The text range to query
171    ///
172    /// # Returns
173    /// The header paragraph if it exists and its text range overlaps with the provided range
174    pub fn header_in_range(&self, range: TextRange) -> Option<Header> {
175        self.header().filter(|h| {
176            let para_range = h.as_deb822().text_range();
177            para_range.start() < range.end() && para_range.end() > range.start()
178        })
179    }
180
181    /// Iterate over files paragraphs that intersect with the given text range
182    ///
183    /// # Arguments
184    /// * `range` - The text range to query
185    ///
186    /// # Returns
187    /// An iterator over files paragraphs whose text ranges overlap with the provided range
188    pub fn iter_files_in_range(
189        &self,
190        range: TextRange,
191    ) -> impl Iterator<Item = FilesParagraph> + '_ {
192        self.iter_files().filter(move |f| {
193            let para_range = f.as_deb822().text_range();
194            para_range.start() < range.end() && para_range.end() > range.start()
195        })
196    }
197
198    /// Iterate over license paragraphs that intersect with the given text range
199    ///
200    /// # Arguments
201    /// * `range` - The text range to query
202    ///
203    /// # Returns
204    /// An iterator over license paragraphs whose text ranges overlap with the provided range
205    pub fn iter_licenses_in_range(
206        &self,
207        range: TextRange,
208    ) -> impl Iterator<Item = LicenseParagraph> + '_ {
209        self.iter_licenses().filter(move |l| {
210            let para_range = l.as_deb822().text_range();
211            para_range.start() < range.end() && para_range.end() > range.start()
212        })
213    }
214
215    /// Returns the Files paragraph for the given filename.
216    ///
217    /// Consistent with the specification, this returns the last paragraph
218    /// that matches (which should be the most specific)
219    pub fn find_files(&self, filename: &Path) -> Option<FilesParagraph> {
220        self.iter_files().filter(|p| p.matches(filename)).last()
221    }
222
223    /// Find license by name
224    ///
225    /// This will return the first license paragraph that has the given name.
226    pub fn find_license_by_name(&self, name: &str) -> Option<License> {
227        self.iter_licenses()
228            .find(|p| p.name().as_deref() == Some(name))
229            .map(|x| x.into())
230    }
231
232    /// Returns the license for the given file.
233    pub fn find_license_for_file(&self, filename: &Path) -> Option<License> {
234        let files = self.find_files(filename)?;
235        let license = files.license()?;
236        if license.text().is_some() {
237            return Some(license);
238        }
239        self.find_license_by_name(license.name()?)
240    }
241
242    /// Read copyright file from a string, allowing syntax errors
243    pub fn from_str_relaxed(s: &str) -> Result<(Self, Vec<String>), Error> {
244        if !s.starts_with("Format:") {
245            return Err(Error::NotMachineReadable);
246        }
247
248        let (deb822, errors) = Deb822::from_str_relaxed(s);
249        Ok((Self(deb822), errors))
250    }
251
252    /// Read copyright file from a file, allowing syntax errors
253    pub fn from_file_relaxed<P: AsRef<Path>>(path: P) -> Result<(Self, Vec<String>), Error> {
254        let text = std::fs::read_to_string(path)?;
255        Self::from_str_relaxed(&text)
256    }
257
258    /// Read copyright file from a file
259    pub fn from_file<P: AsRef<Path>>(path: P) -> Result<Self, Error> {
260        let text = std::fs::read_to_string(path)?;
261        use std::str::FromStr;
262        Self::from_str(&text)
263    }
264
265    /// Add a new files paragraph
266    ///
267    /// Returns a mutable reference to the newly created FilesParagraph
268    pub fn add_files(
269        &mut self,
270        files: &[&str],
271        copyright: &[&str],
272        license: &License,
273    ) -> FilesParagraph {
274        let mut para = self.0.add_paragraph();
275        para.set_with_field_order("Files", &files.join(FILES_SEPARATOR), FILES_FIELD_ORDER);
276        para.set_with_field_order("Copyright", &copyright.join("\n"), FILES_FIELD_ORDER);
277        let license_text = match license {
278            License::Name(name) => name.to_string(),
279            License::Named(name, text) => format!("{}\n{}", name, text),
280            License::Text(text) => text.to_string(),
281        };
282        para.set_with_forced_indent(
283            "License",
284            &license_text,
285            &IndentPattern::Fixed(1),
286            Some(FILES_FIELD_ORDER),
287        );
288        FilesParagraph(para)
289    }
290
291    /// Add a new license paragraph
292    ///
293    /// Returns a mutable reference to the newly created LicenseParagraph
294    pub fn add_license(&mut self, license: &License) -> LicenseParagraph {
295        let mut para = self.0.add_paragraph();
296        let license_text = match license {
297            License::Name(name) => name.to_string(),
298            License::Named(name, text) => format!("{}\n{}", name, encode_field_text(text)),
299            License::Text(text) => encode_field_text(text),
300        };
301        // Force 1-space indentation for License field according to DEP-5 spec
302        para.set_with_indent_pattern(
303            "License",
304            &license_text,
305            Some(&IndentPattern::Fixed(1)),
306            Some(LICENSE_FIELD_ORDER),
307        );
308        LicenseParagraph(para)
309    }
310
311    /// Remove a license paragraph by its short name
312    ///
313    /// This removes the first standalone license paragraph that matches the given name.
314    /// Returns true if a paragraph was removed, false otherwise.
315    pub fn remove_license_by_name(&mut self, name: &str) -> bool {
316        // Find the index of the license paragraph
317        let mut index = None;
318        for (i, para) in self.0.paragraphs().enumerate() {
319            if !para.contains_key("Files")
320                && !para.contains_key("Format")
321                && para.contains_key("License")
322            {
323                let license_para = LicenseParagraph(para);
324                if license_para.name().as_deref() == Some(name) {
325                    index = Some(i);
326                    break;
327                }
328            }
329        }
330
331        if let Some(i) = index {
332            self.0.remove_paragraph(i);
333            true
334        } else {
335            false
336        }
337    }
338
339    /// Remove a files paragraph by matching file pattern
340    ///
341    /// This removes the first files paragraph where the Files field contains the given pattern.
342    /// Returns true if a paragraph was removed, false otherwise.
343    pub fn remove_files_by_pattern(&mut self, pattern: &str) -> bool {
344        // Find the index of the files paragraph
345        let mut index = None;
346        for (i, para) in self.0.paragraphs().enumerate() {
347            if para.contains_key("Files") {
348                let files_para = FilesParagraph(para);
349                if files_para.files().iter().any(|f| f == pattern) {
350                    index = Some(i);
351                    break;
352                }
353            }
354        }
355
356        if let Some(i) = index {
357            self.0.remove_paragraph(i);
358            true
359        } else {
360            false
361        }
362    }
363
364    /// Wrap and sort the entire copyright file
365    ///
366    /// This will:
367    /// - Sort paragraphs according to DEP-5 conventions (header first, Files paragraphs sorted by pattern, License paragraphs last)
368    /// - Sort file patterns within Files paragraphs (wildcards first, debian/* last)
369    /// - Sort fields within each paragraph according to their respective field orders
370    /// - Wrap long lines according to the provided parameters
371    ///
372    /// # Arguments
373    /// * `indentation` - The indentation to use for multi-line fields
374    /// * `immediate_empty_line` - Whether to add an empty line at the start of multi-line fields
375    /// * `max_line_length_one_liner` - The maximum line length for one-liner fields
376    pub fn wrap_and_sort(
377        &mut self,
378        indentation: deb822_lossless::Indentation,
379        immediate_empty_line: bool,
380        max_line_length_one_liner: Option<usize>,
381    ) {
382        // Sort paragraphs: header first, Files paragraphs by pattern, License paragraphs last
383        let sort_paragraphs = |a: &Paragraph, b: &Paragraph| -> std::cmp::Ordering {
384            let a_is_header = a.contains_key("Format");
385            let b_is_header = b.contains_key("Format");
386            let a_is_files = a.contains_key("Files");
387            let b_is_files = b.contains_key("Files");
388
389            // Header always comes first
390            if a_is_header && !b_is_header {
391                return std::cmp::Ordering::Less;
392            }
393            if !a_is_header && b_is_header {
394                return std::cmp::Ordering::Greater;
395            }
396
397            // Files paragraphs come before license paragraphs
398            if a_is_files && !b_is_files && !b_is_header {
399                return std::cmp::Ordering::Less;
400            }
401            if !a_is_files && b_is_files && !a_is_header {
402                return std::cmp::Ordering::Greater;
403            }
404
405            // Sort Files paragraphs by their first file pattern
406            if a_is_files && b_is_files {
407                let a_files = a.get("Files").unwrap_or_default();
408                let b_files = b.get("Files").unwrap_or_default();
409
410                let a_first = a_files.split_whitespace().next().unwrap_or("");
411                let b_first = b_files.split_whitespace().next().unwrap_or("");
412
413                let a_depth = crate::pattern_depth(a_first);
414                let b_depth = crate::pattern_depth(b_first);
415
416                let a_key = crate::pattern_sort_key(a_first, a_depth);
417                let b_key = crate::pattern_sort_key(b_first, b_depth);
418
419                return a_key.cmp(&b_key);
420            }
421
422            std::cmp::Ordering::Equal
423        };
424
425        // Wrap and sort each paragraph based on its type
426        let wrap_and_sort_para = |para: &Paragraph| -> Paragraph {
427            let is_header = para.contains_key("Format");
428            let is_files = para.contains_key("Files");
429
430            if is_header {
431                let mut header = Header(para.clone());
432                header.wrap_and_sort(indentation, immediate_empty_line, max_line_length_one_liner);
433                header.0
434            } else if is_files {
435                let mut files = FilesParagraph(para.clone());
436                files.wrap_and_sort(indentation, immediate_empty_line, max_line_length_one_liner);
437                files.0
438            } else {
439                let mut license = LicenseParagraph(para.clone());
440                license.wrap_and_sort(indentation, immediate_empty_line, max_line_length_one_liner);
441                license.0
442            }
443        };
444
445        self.0 = self
446            .0
447            .wrap_and_sort(Some(&sort_paragraphs), Some(&wrap_and_sort_para));
448    }
449}
450
451/// Error parsing copyright files
452#[derive(Debug)]
453pub enum Error {
454    /// Parse error
455    ParseError(deb822_lossless::ParseError),
456
457    /// IO error
458    IoError(std::io::Error),
459
460    /// Invalid value (e.g., empty continuation lines)
461    InvalidValue(String),
462
463    /// The file is not machine readable
464    NotMachineReadable,
465}
466
467impl From<deb822_lossless::Error> for Error {
468    fn from(e: deb822_lossless::Error) -> Self {
469        match e {
470            deb822_lossless::Error::ParseError(e) => Error::ParseError(e),
471            deb822_lossless::Error::IoError(e) => Error::IoError(e),
472            deb822_lossless::Error::InvalidValue(msg) => Error::InvalidValue(msg),
473        }
474    }
475}
476
477impl From<std::io::Error> for Error {
478    fn from(e: std::io::Error) -> Self {
479        Error::IoError(e)
480    }
481}
482
483impl From<deb822_lossless::ParseError> for Error {
484    fn from(e: deb822_lossless::ParseError) -> Self {
485        Error::ParseError(e)
486    }
487}
488
489impl std::fmt::Display for Error {
490    fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
491        match &self {
492            Error::ParseError(e) => write!(f, "parse error: {}", e),
493            Error::NotMachineReadable => write!(f, "not machine readable"),
494            Error::IoError(e) => write!(f, "io error: {}", e),
495            Error::InvalidValue(msg) => write!(f, "invalid value: {}", msg),
496        }
497    }
498}
499
500impl std::error::Error for Error {}
501
502impl Default for Copyright {
503    fn default() -> Self {
504        Copyright(Deb822::new())
505    }
506}
507
508impl std::str::FromStr for Copyright {
509    type Err = Error;
510
511    fn from_str(s: &str) -> Result<Self, Self::Err> {
512        if !s.starts_with("Format:") {
513            return Err(Error::NotMachineReadable);
514        }
515        Ok(Self(Deb822::from_str(s)?))
516    }
517}
518
519impl std::fmt::Display for Copyright {
520    fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
521        f.write_str(&self.0.to_string())
522    }
523}
524
525/// A header paragraph
526pub struct Header(Paragraph);
527
528impl Header {
529    /// Returns the format string for this file.
530    pub fn format_string(&self) -> Option<String> {
531        self.0
532            .get("Format")
533            .or_else(|| self.0.get("Format-Specification"))
534    }
535
536    /// Return the underlying Deb822 paragraph
537    pub fn as_deb822(&self) -> &Paragraph {
538        &self.0
539    }
540
541    /// Return the underlying Deb822 paragraph, mutably
542    #[deprecated = "Use as_deb822 instead"]
543    pub fn as_mut_deb822(&mut self) -> &mut Paragraph {
544        &mut self.0
545    }
546
547    /// Upstream name
548    pub fn upstream_name(&self) -> Option<String> {
549        self.0.get("Upstream-Name")
550    }
551
552    /// Set the upstream name
553    pub fn set_upstream_name(&mut self, name: &str) {
554        self.0
555            .set_with_field_order("Upstream-Name", name, HEADER_FIELD_ORDER);
556    }
557
558    /// Upstream contact
559    pub fn upstream_contact(&self) -> Option<String> {
560        self.0.get("Upstream-Contact")
561    }
562
563    /// Set the upstream contact
564    pub fn set_upstream_contact(&mut self, contact: &str) {
565        self.0
566            .set_with_field_order("Upstream-Contact", contact, HEADER_FIELD_ORDER);
567    }
568
569    /// Source
570    pub fn source(&self) -> Option<String> {
571        self.0.get("Source")
572    }
573
574    /// Set the source
575    pub fn set_source(&mut self, source: &str) {
576        self.0
577            .set_with_field_order("Source", source, HEADER_FIELD_ORDER);
578    }
579
580    /// List of files excluded from the copyright information, as well as the source package
581    pub fn files_excluded(&self) -> Option<Vec<String>> {
582        self.0
583            .get("Files-Excluded")
584            .map(|x| x.split('\n').map(|x| x.to_string()).collect::<Vec<_>>())
585    }
586
587    /// Set excluded files
588    pub fn set_files_excluded(&mut self, files: &[&str]) {
589        self.0
590            .set_with_field_order("Files-Excluded", &files.join("\n"), HEADER_FIELD_ORDER);
591    }
592
593    /// Fix the the header paragraph
594    ///
595    /// Currently this just renames `Format-Specification` to `Format` and replaces older format
596    /// strings with the current format string.
597    pub fn fix(&mut self) {
598        if self.0.contains_key("Format-Specification") {
599            self.0.rename("Format-Specification", "Format");
600        }
601
602        if let Some(mut format) = self.0.get("Format") {
603            if !format.ends_with('/') {
604                format.push('/');
605            }
606
607            if let Some(rest) = format.strip_prefix("http:") {
608                format = format!("https:{}", rest);
609            }
610
611            if KNOWN_FORMATS.contains(&format.as_str()) {
612                format = CURRENT_FORMAT.to_string();
613            }
614
615            self.0.set("Format", format.as_str());
616        }
617    }
618
619    /// Wrap and sort the header paragraph
620    ///
621    /// # Arguments
622    /// * `indentation` - The indentation to use
623    /// * `immediate_empty_line` - Whether to add an empty line at the start of multi-line fields
624    /// * `max_line_length_one_liner` - The maximum line length for one-liner fields
625    pub fn wrap_and_sort(
626        &mut self,
627        indentation: deb822_lossless::Indentation,
628        immediate_empty_line: bool,
629        max_line_length_one_liner: Option<usize>,
630    ) {
631        let sort_entries =
632            |a: &deb822_lossless::Entry, b: &deb822_lossless::Entry| -> std::cmp::Ordering {
633                let a_key = a.key().unwrap_or_default();
634                let b_key = b.key().unwrap_or_default();
635                let a_pos = HEADER_FIELD_ORDER.iter().position(|&k| k == a_key);
636                let b_pos = HEADER_FIELD_ORDER.iter().position(|&k| k == b_key);
637                match (a_pos, b_pos) {
638                    (Some(a_idx), Some(b_idx)) => a_idx.cmp(&b_idx),
639                    (Some(_), None) => std::cmp::Ordering::Less,
640                    (None, Some(_)) => std::cmp::Ordering::Greater,
641                    (None, None) => std::cmp::Ordering::Equal,
642                }
643            };
644        self.0 = self.0.wrap_and_sort(
645            indentation,
646            immediate_empty_line,
647            max_line_length_one_liner,
648            Some(&sort_entries),
649            None,
650        );
651    }
652}
653
654/// A files paragraph
655pub struct FilesParagraph(Paragraph);
656
657impl FilesParagraph {
658    /// Return the underlying Deb822 paragraph
659    pub fn as_deb822(&self) -> &Paragraph {
660        &self.0
661    }
662
663    /// List of file patterns in the paragraph
664    pub fn files(&self) -> Vec<String> {
665        self.0
666            .get("Files")
667            .unwrap()
668            .split_whitespace()
669            .map(|v| v.to_string())
670            .collect::<Vec<_>>()
671    }
672
673    /// Set the file patterns in the paragraph
674    pub fn set_files(&mut self, files: &[&str]) {
675        self.0
676            .set_with_field_order("Files", &files.join(FILES_SEPARATOR), FILES_FIELD_ORDER);
677    }
678
679    /// Add a file pattern to the paragraph
680    ///
681    /// If the pattern already exists, it will not be added again.
682    pub fn add_file(&mut self, pattern: &str) {
683        let mut files = self.files();
684        if !files.contains(&pattern.to_string()) {
685            files.push(pattern.to_string());
686            self.0
687                .set_with_field_order("Files", &files.join(FILES_SEPARATOR), FILES_FIELD_ORDER);
688        }
689    }
690
691    /// Remove a file pattern from the paragraph
692    ///
693    /// Returns true if the pattern was found and removed, false otherwise.
694    pub fn remove_file(&mut self, pattern: &str) -> bool {
695        let mut files = self.files();
696        if let Some(pos) = files.iter().position(|f| f == pattern) {
697            files.remove(pos);
698            self.0
699                .set_with_field_order("Files", &files.join(FILES_SEPARATOR), FILES_FIELD_ORDER);
700            true
701        } else {
702            false
703        }
704    }
705
706    /// Check whether the paragraph matches the given filename
707    pub fn matches(&self, filename: &std::path::Path) -> bool {
708        self.files()
709            .iter()
710            .any(|f| crate::glob::glob_to_regex(f).is_match(filename.to_str().unwrap()))
711    }
712
713    /// Copyright holders in the paragraph
714    pub fn copyright(&self) -> Vec<String> {
715        self.0
716            .get("Copyright")
717            .unwrap_or_default()
718            .split('\n')
719            .map(|x| x.to_string())
720            .collect::<Vec<_>>()
721    }
722
723    /// Set the copyright
724    pub fn set_copyright(&mut self, authors: &[&str]) {
725        self.0
726            .set_with_field_order("Copyright", &authors.join("\n"), FILES_FIELD_ORDER);
727    }
728
729    /// Comment associated with the files paragraph
730    pub fn comment(&self) -> Option<String> {
731        self.0.get("Comment")
732    }
733
734    /// Set the comment associated with the files paragraph
735    pub fn set_comment(&mut self, comment: &str) {
736        self.0
737            .set_with_field_order("Comment", comment, FILES_FIELD_ORDER);
738    }
739
740    /// License in the paragraph
741    pub fn license(&self) -> Option<License> {
742        self.0.get_multiline("License").map(|x| {
743            x.split_once('\n').map_or_else(
744                || License::Name(x.to_string()),
745                |(name, text)| {
746                    if name.is_empty() {
747                        License::Text(text.to_string())
748                    } else {
749                        License::Named(name.to_string(), text.to_string())
750                    }
751                },
752            )
753        })
754    }
755
756    /// Set the license associated with the files paragraph
757    pub fn set_license(&mut self, license: &License) {
758        let text = match license {
759            License::Name(name) => name.to_string(),
760            License::Named(name, text) => format!("{}\n{}", name, encode_field_text(text)),
761            License::Text(text) => encode_field_text(text),
762        };
763        // Force 1-space indentation for License field according to DEP-5 spec
764        let indent_pattern = deb822_lossless::IndentPattern::Fixed(1);
765        self.0
766            .set_with_forced_indent("License", &text, &indent_pattern, Some(FILES_FIELD_ORDER));
767    }
768
769    /// Wrap and sort the files paragraph
770    ///
771    /// # Arguments
772    /// * `indentation` - The indentation to use
773    /// * `immediate_empty_line` - Whether to add an empty line at the start of multi-line fields
774    /// * `max_line_length_one_liner` - The maximum line length for one-liner fields
775    pub fn wrap_and_sort(
776        &mut self,
777        indentation: deb822_lossless::Indentation,
778        immediate_empty_line: bool,
779        max_line_length_one_liner: Option<usize>,
780    ) {
781        let sort_entries =
782            |a: &deb822_lossless::Entry, b: &deb822_lossless::Entry| -> std::cmp::Ordering {
783                let a_key = a.key().unwrap_or_default();
784                let b_key = b.key().unwrap_or_default();
785                let a_pos = FILES_FIELD_ORDER.iter().position(|&k| k == a_key);
786                let b_pos = FILES_FIELD_ORDER.iter().position(|&k| k == b_key);
787                match (a_pos, b_pos) {
788                    (Some(a_idx), Some(b_idx)) => a_idx.cmp(&b_idx),
789                    (Some(_), None) => std::cmp::Ordering::Less,
790                    (None, Some(_)) => std::cmp::Ordering::Greater,
791                    (None, None) => std::cmp::Ordering::Equal,
792                }
793            };
794
795        let format_value = |key: &str, value: &str| -> String {
796            if key == "Files" {
797                let mut patterns: Vec<_> = value.split_whitespace().collect();
798                patterns.sort_by_key(|p| {
799                    let depth = crate::pattern_depth(p);
800                    crate::pattern_sort_key(p, depth)
801                });
802                patterns.join(FILES_SEPARATOR)
803            } else {
804                value.to_string()
805            }
806        };
807
808        self.0 = self.0.wrap_and_sort(
809            indentation,
810            immediate_empty_line,
811            max_line_length_one_liner,
812            Some(&sort_entries),
813            Some(&format_value),
814        );
815    }
816}
817
818/// A paragraph that contains a license
819pub struct LicenseParagraph(Paragraph);
820
821impl From<LicenseParagraph> for License {
822    fn from(p: LicenseParagraph) -> Self {
823        let x = p.0.get_multiline("License").unwrap();
824        x.split_once('\n').map_or_else(
825            || License::Name(x.to_string()),
826            |(name, text)| {
827                if name.is_empty() {
828                    License::Text(text.to_string())
829                } else {
830                    License::Named(name.to_string(), text.to_string())
831                }
832            },
833        )
834    }
835}
836
837impl LicenseParagraph {
838    /// Return the underlying Deb822 paragraph
839    pub fn as_deb822(&self) -> &Paragraph {
840        &self.0
841    }
842
843    /// Comment associated with the license
844    pub fn comment(&self) -> Option<String> {
845        self.0.get("Comment")
846    }
847
848    /// Set the comment associated with the license
849    pub fn set_comment(&mut self, comment: &str) {
850        self.0
851            .set_with_field_order("Comment", comment, LICENSE_FIELD_ORDER);
852    }
853
854    /// Name of the license
855    pub fn name(&self) -> Option<String> {
856        self.0
857            .get_multiline("License")
858            .and_then(|x| x.split_once('\n').map(|(name, _)| name.to_string()))
859    }
860
861    /// Text of the license
862    pub fn text(&self) -> Option<String> {
863        self.0
864            .get_multiline("License")
865            .and_then(|x| x.split_once('\n').map(|(_, text)| decode_field_text(text)))
866    }
867
868    /// Get the license as a License enum
869    pub fn license(&self) -> License {
870        let x = self.0.get_multiline("License").unwrap();
871        x.split_once('\n').map_or_else(
872            || License::Name(x.to_string()),
873            |(name, text)| {
874                let decoded_text = decode_field_text(text);
875                if name.is_empty() {
876                    License::Text(decoded_text)
877                } else {
878                    License::Named(name.to_string(), decoded_text)
879                }
880            },
881        )
882    }
883
884    /// Set the license
885    pub fn set_license(&mut self, license: &License) {
886        let text = match license {
887            License::Name(name) => name.to_string(),
888            License::Named(name, text) => format!("{}\n{}", name, encode_field_text(text)),
889            License::Text(text) => encode_field_text(text),
890        };
891        // Force 1-space indentation for License field according to DEP-5 spec
892        let indent_pattern = deb822_lossless::IndentPattern::Fixed(1);
893        self.0
894            .set_with_forced_indent("License", &text, &indent_pattern, Some(LICENSE_FIELD_ORDER));
895    }
896
897    /// Set just the license name (short name on the first line)
898    ///
899    /// If the license currently has text, it will be preserved.
900    /// If the license has no text, this will set it to just a name.
901    pub fn set_name(&mut self, name: &str) {
902        let current = self.license();
903        let new_license = match current {
904            License::Named(_, text) | License::Text(text) => License::Named(name.to_string(), text),
905            License::Name(_) => License::Name(name.to_string()),
906        };
907        self.set_license(&new_license);
908    }
909
910    /// Set just the license text (the full license text after the first line)
911    ///
912    /// If text is None, removes the license text while keeping the name.
913    /// If the license currently has a name, it will be preserved.
914    /// If the license has no name and text is Some, this will create a license with just text.
915    pub fn set_text(&mut self, text: Option<&str>) {
916        let current = self.license();
917        let new_license = match (current, text) {
918            (License::Named(name, _), Some(new_text)) | (License::Name(name), Some(new_text)) => {
919                License::Named(name, new_text.to_string())
920            }
921            (License::Named(name, _), None) | (License::Name(name), None) => License::Name(name),
922            (License::Text(_), Some(new_text)) => License::Text(new_text.to_string()),
923            (License::Text(_), None) => {
924                // Edge case: removing text from a text-only license. Set empty name.
925                License::Name(String::new())
926            }
927        };
928        self.set_license(&new_license);
929    }
930
931    /// Wrap and sort the license paragraph
932    ///
933    /// # Arguments
934    /// * `indentation` - The indentation to use
935    /// * `immediate_empty_line` - Whether to add an empty line at the start of multi-line fields
936    /// * `max_line_length_one_liner` - The maximum line length for one-liner fields
937    pub fn wrap_and_sort(
938        &mut self,
939        indentation: deb822_lossless::Indentation,
940        immediate_empty_line: bool,
941        max_line_length_one_liner: Option<usize>,
942    ) {
943        let sort_entries =
944            |a: &deb822_lossless::Entry, b: &deb822_lossless::Entry| -> std::cmp::Ordering {
945                let a_key = a.key().unwrap_or_default();
946                let b_key = b.key().unwrap_or_default();
947                let a_pos = LICENSE_FIELD_ORDER.iter().position(|&k| k == a_key);
948                let b_pos = LICENSE_FIELD_ORDER.iter().position(|&k| k == b_key);
949                match (a_pos, b_pos) {
950                    (Some(a_idx), Some(b_idx)) => a_idx.cmp(&b_idx),
951                    (Some(_), None) => std::cmp::Ordering::Less,
952                    (None, Some(_)) => std::cmp::Ordering::Greater,
953                    (None, None) => std::cmp::Ordering::Equal,
954                }
955            };
956        self.0 = self.0.wrap_and_sort(
957            indentation,
958            immediate_empty_line,
959            max_line_length_one_liner,
960            Some(&sort_entries),
961            None,
962        );
963    }
964}
965
966#[cfg(test)]
967mod tests {
968    use deb822_lossless::{TextRange, TextSize};
969
970    #[test]
971    fn test_not_machine_readable() {
972        let s = r#"
973This copyright file is not machine readable.
974"#;
975        let ret = s.parse::<super::Copyright>();
976        assert!(ret.is_err());
977        assert!(matches!(ret.unwrap_err(), super::Error::NotMachineReadable));
978    }
979
980    #[test]
981    fn test_new() {
982        let n = super::Copyright::new();
983        assert_eq!(
984            n.to_string().as_str(),
985            "Format: https://www.debian.org/doc/packaging-manuals/copyright-format/1.0/\n"
986        );
987    }
988
989    #[test]
990    fn test_parse() {
991        let s = r#"Format: https://www.debian.org/doc/packaging-manuals/copyright-format/1.0/
992Upstream-Name: foo
993Upstream-Contact: Joe Bloggs <joe@example.com>
994Source: https://example.com/foo
995
996Files: *
997Copyright:
998  2020 Joe Bloggs <joe@example.com>
999License: GPL-3+
1000
1001Files: debian/*
1002Comment: Debian packaging is licensed under the GPL-3+.
1003Copyright: 2023 Jelmer Vernooij
1004License: GPL-3+
1005
1006License: GPL-3+
1007 This program is free software: you can redistribute it and/or modify
1008 it under the terms of the GNU General Public License as published by
1009 the Free Software Foundation, either version 3 of the License, or
1010 (at your option) any later version.
1011"#;
1012        let copyright = s.parse::<super::Copyright>().expect("failed to parse");
1013
1014        assert_eq!(
1015            "https://www.debian.org/doc/packaging-manuals/copyright-format/1.0/",
1016            copyright.header().unwrap().format_string().unwrap()
1017        );
1018        assert_eq!("foo", copyright.header().unwrap().upstream_name().unwrap());
1019        assert_eq!(
1020            "Joe Bloggs <joe@example.com>",
1021            copyright.header().unwrap().upstream_contact().unwrap()
1022        );
1023        assert_eq!(
1024            "https://example.com/foo",
1025            copyright.header().unwrap().source().unwrap()
1026        );
1027
1028        let files = copyright.iter_files().collect::<Vec<_>>();
1029        assert_eq!(2, files.len());
1030        assert_eq!("*", files[0].files().join(" "));
1031        assert_eq!("debian/*", files[1].files().join(" "));
1032        assert_eq!(
1033            "Debian packaging is licensed under the GPL-3+.",
1034            files[1].comment().unwrap()
1035        );
1036        assert_eq!(
1037            vec!["2023 Jelmer Vernooij".to_string()],
1038            files[1].copyright()
1039        );
1040        assert_eq!("GPL-3+", files[1].license().unwrap().name().unwrap());
1041        assert_eq!(files[1].license().unwrap().text(), None);
1042
1043        let licenses = copyright.iter_licenses().collect::<Vec<_>>();
1044        assert_eq!(1, licenses.len());
1045        assert_eq!("GPL-3+", licenses[0].name().unwrap());
1046        assert_eq!(
1047            "This program is free software: you can redistribute it and/or modify
1048it under the terms of the GNU General Public License as published by
1049the Free Software Foundation, either version 3 of the License, or
1050(at your option) any later version.",
1051            licenses[0].text().unwrap()
1052        );
1053
1054        let upstream_files = copyright.find_files(std::path::Path::new("foo.c")).unwrap();
1055        assert_eq!(vec!["*"], upstream_files.files());
1056
1057        let debian_files = copyright
1058            .find_files(std::path::Path::new("debian/foo.c"))
1059            .unwrap();
1060        assert_eq!(vec!["debian/*"], debian_files.files());
1061
1062        let gpl = copyright.find_license_by_name("GPL-3+");
1063        assert!(gpl.is_some());
1064
1065        let gpl = copyright.find_license_for_file(std::path::Path::new("debian/foo.c"));
1066        assert_eq!(gpl.unwrap().name().unwrap(), "GPL-3+");
1067    }
1068
1069    #[test]
1070    fn test_from_str_relaxed() {
1071        let s = r#"Format: https://www.debian.org/doc/packaging-manuals/copyright-format/1.0/
1072Upstream-Name: foo
1073Source: https://example.com/foo
1074
1075Files: *
1076Copyright: 2020 Joe Bloggs <joe@example.com>
1077License: GPL-3+
1078"#;
1079        let (copyright, errors) = super::Copyright::from_str_relaxed(s).unwrap();
1080        assert!(errors.is_empty());
1081        assert_eq!("foo", copyright.header().unwrap().upstream_name().unwrap());
1082    }
1083
1084    #[test]
1085    fn test_from_file_relaxed() {
1086        let tmpfile = std::env::temp_dir().join("test_copyright.txt");
1087        std::fs::write(
1088            &tmpfile,
1089            r#"Format: https://www.debian.org/doc/packaging-manuals/copyright-format/1.0/
1090Upstream-Name: foo
1091Source: https://example.com/foo
1092
1093Files: *
1094Copyright: 2020 Joe Bloggs <joe@example.com>
1095License: GPL-3+
1096"#,
1097        )
1098        .unwrap();
1099        let (copyright, errors) = super::Copyright::from_file_relaxed(&tmpfile).unwrap();
1100        assert!(errors.is_empty());
1101        assert_eq!("foo", copyright.header().unwrap().upstream_name().unwrap());
1102        std::fs::remove_file(&tmpfile).unwrap();
1103    }
1104
1105    #[test]
1106    fn test_header_set_upstream_contact() {
1107        let copyright = super::Copyright::new();
1108        let mut header = copyright.header().unwrap();
1109        header.set_upstream_contact("Test Person <test@example.com>");
1110        assert_eq!(
1111            header.upstream_contact().unwrap(),
1112            "Test Person <test@example.com>"
1113        );
1114    }
1115
1116    #[test]
1117    fn test_header_set_source() {
1118        let copyright = super::Copyright::new();
1119        let mut header = copyright.header().unwrap();
1120        header.set_source("https://example.com/source");
1121        assert_eq!(header.source().unwrap(), "https://example.com/source");
1122    }
1123
1124    #[test]
1125    fn test_license_paragraph_set_comment() {
1126        let s = r#"Format: https://www.debian.org/doc/packaging-manuals/copyright-format/1.0/
1127
1128License: GPL-3+
1129 This is the license text.
1130"#;
1131        let copyright = s.parse::<super::Copyright>().unwrap();
1132        let mut license = copyright.iter_licenses().next().unwrap();
1133        license.set_comment("This is a test comment");
1134        assert_eq!(license.comment().unwrap(), "This is a test comment");
1135    }
1136
1137    #[test]
1138    fn test_license_paragraph_set_license() {
1139        let s = r#"Format: https://www.debian.org/doc/packaging-manuals/copyright-format/1.0/
1140
1141License: GPL-3+
1142 Old license text.
1143"#;
1144        let copyright = s.parse::<super::Copyright>().unwrap();
1145        let mut license = copyright.iter_licenses().next().unwrap();
1146
1147        let new_license = crate::License::Named(
1148            "MIT".to_string(),
1149            "Permission is hereby granted...".to_string(),
1150        );
1151        license.set_license(&new_license);
1152
1153        assert_eq!(license.name().unwrap(), "MIT");
1154        assert_eq!(license.text().unwrap(), "Permission is hereby granted...");
1155    }
1156
1157    #[test]
1158    fn test_iter_licenses_excludes_header() {
1159        // Test that iter_licenses does not include the header paragraph even if it has a License field
1160        let s = r#"Format: https://www.debian.org/doc/packaging-manuals/copyright-format/1.0/
1161Upstream-Name: foo
1162License: GPL-3+
1163
1164Files: *
1165Copyright: 2020 Joe Bloggs
1166License: MIT
1167
1168License: GPL-3+
1169 This is the GPL-3+ license text.
1170"#;
1171        let copyright = s.parse::<super::Copyright>().unwrap();
1172        let licenses: Vec<_> = copyright.iter_licenses().collect();
1173
1174        // Should only have the standalone License paragraph, not the header
1175        assert_eq!(1, licenses.len());
1176        assert_eq!("GPL-3+", licenses[0].name().unwrap());
1177        assert_eq!(
1178            "This is the GPL-3+ license text.",
1179            licenses[0].text().unwrap()
1180        );
1181    }
1182
1183    #[test]
1184    fn test_add_files() {
1185        let mut copyright = super::Copyright::new();
1186        let license = crate::License::Name("GPL-3+".to_string());
1187        copyright.add_files(
1188            &["src/*", "*.rs"],
1189            &["2024 John Doe", "2024 Jane Doe"],
1190            &license,
1191        );
1192
1193        let files: Vec<_> = copyright.iter_files().collect();
1194        assert_eq!(1, files.len());
1195        assert_eq!(vec!["src/*", "*.rs"], files[0].files());
1196        assert_eq!(vec!["2024 John Doe", "2024 Jane Doe"], files[0].copyright());
1197        assert_eq!("GPL-3+", files[0].license().unwrap().name().unwrap());
1198
1199        // Verify the generated format
1200        assert_eq!(
1201            copyright.to_string(),
1202            "Format: https://www.debian.org/doc/packaging-manuals/copyright-format/1.0/\n\n\
1203             Files: src/* *.rs\n\
1204             Copyright: 2024 John Doe\n           2024 Jane Doe\n\
1205             License: GPL-3+\n"
1206        );
1207    }
1208
1209    #[test]
1210    fn test_add_files_with_license_text() {
1211        let mut copyright = super::Copyright::new();
1212        let license = crate::License::Named(
1213            "MIT".to_string(),
1214            "Permission is hereby granted...".to_string(),
1215        );
1216        copyright.add_files(&["*"], &["2024 Test Author"], &license);
1217
1218        let files: Vec<_> = copyright.iter_files().collect();
1219        assert_eq!(1, files.len());
1220        assert_eq!("MIT", files[0].license().unwrap().name().unwrap());
1221        assert_eq!(
1222            "Permission is hereby granted...",
1223            files[0].license().unwrap().text().unwrap()
1224        );
1225
1226        // Verify the generated format
1227        assert_eq!(
1228            copyright.to_string(),
1229            "Format: https://www.debian.org/doc/packaging-manuals/copyright-format/1.0/\n\n\
1230             Files: *\n\
1231             Copyright: 2024 Test Author\n\
1232             License: MIT\n Permission is hereby granted...\n"
1233        );
1234    }
1235
1236    #[test]
1237    fn test_add_license() {
1238        let mut copyright = super::Copyright::new();
1239        let license = crate::License::Named(
1240            "GPL-3+".to_string(),
1241            "This is the GPL-3+ license text.".to_string(),
1242        );
1243        copyright.add_license(&license);
1244
1245        let licenses: Vec<_> = copyright.iter_licenses().collect();
1246        assert_eq!(1, licenses.len());
1247        assert_eq!("GPL-3+", licenses[0].name().unwrap());
1248        assert_eq!(
1249            "This is the GPL-3+ license text.",
1250            licenses[0].text().unwrap()
1251        );
1252
1253        // Verify the generated format
1254        assert_eq!(
1255            copyright.to_string(),
1256            "Format: https://www.debian.org/doc/packaging-manuals/copyright-format/1.0/\n\n\
1257             License: GPL-3+\n This is the GPL-3+ license text.\n"
1258        );
1259    }
1260
1261    #[test]
1262    fn test_add_multiple_paragraphs() {
1263        let mut copyright = super::Copyright::new();
1264
1265        // Add a files paragraph
1266        let license1 = crate::License::Name("MIT".to_string());
1267        copyright.add_files(&["src/*"], &["2024 Author One"], &license1);
1268
1269        // Add another files paragraph
1270        let license2 = crate::License::Name("GPL-3+".to_string());
1271        copyright.add_files(&["debian/*"], &["2024 Author Two"], &license2);
1272
1273        // Add a license paragraph
1274        let license3 =
1275            crate::License::Named("GPL-3+".to_string(), "Full GPL-3+ text here.".to_string());
1276        copyright.add_license(&license3);
1277
1278        // Verify all paragraphs were added
1279        assert_eq!(2, copyright.iter_files().count());
1280        assert_eq!(1, copyright.iter_licenses().count());
1281
1282        let files: Vec<_> = copyright.iter_files().collect();
1283        assert_eq!(vec!["src/*"], files[0].files());
1284        assert_eq!(vec!["debian/*"], files[1].files());
1285
1286        let licenses: Vec<_> = copyright.iter_licenses().collect();
1287        assert_eq!("GPL-3+", licenses[0].name().unwrap());
1288        assert_eq!("Full GPL-3+ text here.", licenses[0].text().unwrap());
1289
1290        // Verify the generated format
1291        assert_eq!(
1292            copyright.to_string(),
1293            "Format: https://www.debian.org/doc/packaging-manuals/copyright-format/1.0/\n\n\
1294             Files: src/*\n\
1295             Copyright: 2024 Author One\n\
1296             License: MIT\n\n\
1297             Files: debian/*\n\
1298             Copyright: 2024 Author Two\n\
1299             License: GPL-3+\n\n\
1300             License: GPL-3+\n Full GPL-3+ text here.\n"
1301        );
1302    }
1303
1304    #[test]
1305    fn test_remove_license_by_name() {
1306        let mut copyright = super::Copyright::new();
1307
1308        // Add multiple license paragraphs
1309        let license1 = crate::License::Named("MIT".to_string(), "MIT license text.".to_string());
1310        copyright.add_license(&license1);
1311
1312        let license2 =
1313            crate::License::Named("GPL-3+".to_string(), "GPL-3+ license text.".to_string());
1314        copyright.add_license(&license2);
1315
1316        let license3 =
1317            crate::License::Named("Apache-2.0".to_string(), "Apache license text.".to_string());
1318        copyright.add_license(&license3);
1319
1320        // Verify we have 3 license paragraphs
1321        assert_eq!(3, copyright.iter_licenses().count());
1322
1323        // Remove the GPL-3+ license
1324        let removed = copyright.remove_license_by_name("GPL-3+");
1325        assert!(removed);
1326
1327        // Verify we now have 2 license paragraphs
1328        assert_eq!(2, copyright.iter_licenses().count());
1329
1330        // Verify the remaining licenses
1331        let licenses: Vec<_> = copyright.iter_licenses().collect();
1332        assert_eq!("MIT", licenses[0].name().unwrap());
1333        assert_eq!("Apache-2.0", licenses[1].name().unwrap());
1334
1335        // Try to remove a non-existent license
1336        let removed = copyright.remove_license_by_name("BSD-3-Clause");
1337        assert!(!removed);
1338        assert_eq!(2, copyright.iter_licenses().count());
1339    }
1340
1341    #[test]
1342    fn test_remove_files_by_pattern() {
1343        let mut copyright = super::Copyright::new();
1344
1345        // Add multiple files paragraphs
1346        let license1 = crate::License::Name("MIT".to_string());
1347        copyright.add_files(&["src/*"], &["2024 Author One"], &license1);
1348
1349        let license2 = crate::License::Name("GPL-3+".to_string());
1350        copyright.add_files(&["debian/*"], &["2024 Author Two"], &license2);
1351
1352        let license3 = crate::License::Name("Apache-2.0".to_string());
1353        copyright.add_files(&["docs/*"], &["2024 Author Three"], &license3);
1354
1355        // Verify we have 3 files paragraphs
1356        assert_eq!(3, copyright.iter_files().count());
1357
1358        // Remove the debian/* files paragraph
1359        let removed = copyright.remove_files_by_pattern("debian/*");
1360        assert!(removed);
1361
1362        // Verify we now have 2 files paragraphs
1363        assert_eq!(2, copyright.iter_files().count());
1364
1365        // Verify the remaining files paragraphs
1366        let files: Vec<_> = copyright.iter_files().collect();
1367        assert_eq!(vec!["src/*"], files[0].files());
1368        assert_eq!(vec!["docs/*"], files[1].files());
1369
1370        // Try to remove a non-existent pattern
1371        let removed = copyright.remove_files_by_pattern("tests/*");
1372        assert!(!removed);
1373        assert_eq!(2, copyright.iter_files().count());
1374    }
1375
1376    #[test]
1377    fn test_remove_files_by_pattern_with_multiple_patterns() {
1378        let mut copyright = super::Copyright::new();
1379
1380        // Add a files paragraph with multiple patterns
1381        let license = crate::License::Name("MIT".to_string());
1382        copyright.add_files(&["src/*", "*.rs"], &["2024 Author"], &license);
1383
1384        // Verify we have 1 files paragraph
1385        assert_eq!(1, copyright.iter_files().count());
1386
1387        // Remove by matching one of the patterns
1388        let removed = copyright.remove_files_by_pattern("*.rs");
1389        assert!(removed);
1390
1391        // Verify the paragraph was removed
1392        assert_eq!(0, copyright.iter_files().count());
1393    }
1394
1395    #[test]
1396    fn test_license_paragraph_set_name() {
1397        let s = r#"Format: https://www.debian.org/doc/packaging-manuals/copyright-format/1.0/
1398
1399License: GPL-3+
1400 This is the GPL-3+ license text.
1401"#;
1402        let copyright = s.parse::<super::Copyright>().unwrap();
1403        let mut license = copyright.iter_licenses().next().unwrap();
1404
1405        // Change just the name, preserving the text
1406        license.set_name("Apache-2.0");
1407
1408        assert_eq!(license.name().unwrap(), "Apache-2.0");
1409        assert_eq!(license.text().unwrap(), "This is the GPL-3+ license text.");
1410    }
1411
1412    #[test]
1413    fn test_license_paragraph_set_name_no_text() {
1414        let s = r#"Format: https://www.debian.org/doc/packaging-manuals/copyright-format/1.0/
1415
1416License: GPL-3+
1417"#;
1418        let copyright = s.parse::<super::Copyright>().unwrap();
1419        let mut license = copyright.iter_licenses().next().unwrap();
1420
1421        // Change just the name when there's no text
1422        license.set_name("MIT");
1423
1424        assert_eq!(license.license(), crate::License::Name("MIT".to_string()));
1425        assert_eq!(license.text(), None);
1426    }
1427
1428    #[test]
1429    fn test_license_paragraph_set_text() {
1430        let s = r#"Format: https://www.debian.org/doc/packaging-manuals/copyright-format/1.0/
1431
1432License: GPL-3+
1433 Old license text.
1434"#;
1435        let copyright = s.parse::<super::Copyright>().unwrap();
1436        let mut license = copyright.iter_licenses().next().unwrap();
1437
1438        // Change just the text, preserving the name
1439        license.set_text(Some("New license text."));
1440
1441        assert_eq!(license.name().unwrap(), "GPL-3+");
1442        assert_eq!(license.text().unwrap(), "New license text.");
1443    }
1444
1445    #[test]
1446    fn test_license_paragraph_set_text_remove() {
1447        let s = r#"Format: https://www.debian.org/doc/packaging-manuals/copyright-format/1.0/
1448
1449License: GPL-3+
1450 Old license text.
1451"#;
1452        let copyright = s.parse::<super::Copyright>().unwrap();
1453        let mut license = copyright.iter_licenses().next().unwrap();
1454
1455        // Remove the text, keeping just the name
1456        license.set_text(None);
1457
1458        assert_eq!(
1459            license.license(),
1460            crate::License::Name("GPL-3+".to_string())
1461        );
1462        assert_eq!(license.text(), None);
1463    }
1464
1465    #[test]
1466    fn test_license_paragraph_set_text_add() {
1467        let s = r#"Format: https://www.debian.org/doc/packaging-manuals/copyright-format/1.0/
1468
1469License: GPL-3+
1470"#;
1471        let copyright = s.parse::<super::Copyright>().unwrap();
1472        let mut license = copyright.iter_licenses().next().unwrap();
1473
1474        // Add text to a name-only license
1475        license.set_text(Some("This is the full GPL-3+ license text."));
1476
1477        assert_eq!(license.name().unwrap(), "GPL-3+");
1478        assert_eq!(
1479            license.text().unwrap(),
1480            "This is the full GPL-3+ license text."
1481        );
1482    }
1483
1484    #[test]
1485    fn test_files_paragraph_set_files() {
1486        let s = r#"Format: https://www.debian.org/doc/packaging-manuals/copyright-format/1.0/
1487
1488Files: *
1489Copyright: 2024 Test Author
1490License: MIT
1491"#;
1492        let copyright = s.parse::<super::Copyright>().unwrap();
1493        let mut files = copyright.iter_files().next().unwrap();
1494
1495        // Set new file patterns
1496        files.set_files(&["src/*", "*.rs", "tests/*"]);
1497
1498        // Verify the files were updated
1499        assert_eq!(vec!["src/*", "*.rs", "tests/*"], files.files());
1500    }
1501
1502    #[test]
1503    fn test_files_paragraph_add_file() {
1504        let s = r#"Format: https://www.debian.org/doc/packaging-manuals/copyright-format/1.0/
1505
1506Files: src/*
1507Copyright: 2024 Test Author
1508License: MIT
1509"#;
1510        let copyright = s.parse::<super::Copyright>().unwrap();
1511        let mut files = copyright.iter_files().next().unwrap();
1512
1513        // Add a new file pattern
1514        files.add_file("*.rs");
1515        assert_eq!(vec!["src/*", "*.rs"], files.files());
1516
1517        // Add another pattern
1518        files.add_file("tests/*");
1519        assert_eq!(vec!["src/*", "*.rs", "tests/*"], files.files());
1520
1521        // Try to add a duplicate - should not be added
1522        files.add_file("*.rs");
1523        assert_eq!(vec!["src/*", "*.rs", "tests/*"], files.files());
1524    }
1525
1526    #[test]
1527    fn test_files_paragraph_remove_file() {
1528        let s = r#"Format: https://www.debian.org/doc/packaging-manuals/copyright-format/1.0/
1529
1530Files: src/* *.rs tests/*
1531Copyright: 2024 Test Author
1532License: MIT
1533"#;
1534        let copyright = s.parse::<super::Copyright>().unwrap();
1535        let mut files = copyright.iter_files().next().unwrap();
1536
1537        // Remove a file pattern
1538        let removed = files.remove_file("*.rs");
1539        assert!(removed);
1540        assert_eq!(vec!["src/*", "tests/*"], files.files());
1541
1542        // Remove another pattern
1543        let removed = files.remove_file("tests/*");
1544        assert!(removed);
1545        assert_eq!(vec!["src/*"], files.files());
1546
1547        // Try to remove a non-existent pattern
1548        let removed = files.remove_file("debian/*");
1549        assert!(!removed);
1550        assert_eq!(vec!["src/*"], files.files());
1551    }
1552
1553    #[test]
1554    fn test_field_order_with_comment() {
1555        // Test that fields follow DEP-5 order: Files, Copyright, License, Comment
1556        let mut copyright = super::Copyright::new();
1557
1558        let files = vec!["*"];
1559        let copyrights = vec!["Unknown"];
1560        let license = crate::License::Name("GPL-2+".to_string());
1561
1562        let mut para = copyright.add_files(&files, &copyrights, &license);
1563        para.set_comment("Test comment");
1564
1565        let output = copyright.to_string();
1566
1567        // Expected order: Format, blank line, Files, Copyright, License, Comment
1568        let expected =
1569            "Format: https://www.debian.org/doc/packaging-manuals/copyright-format/1.0/\n\n\
1570                        Files: *\n\
1571                        Copyright: Unknown\n\
1572                        License: GPL-2+\n\
1573                        Comment: Test comment\n";
1574
1575        assert_eq!(
1576            output, expected,
1577            "Fields should be in DEP-5 order (Files, Copyright, License, Comment), but got:\n{}",
1578            output
1579        );
1580    }
1581
1582    #[test]
1583    fn test_license_text_decoding_paragraph_markers() {
1584        // Test that paragraph markers (.) are decoded to blank lines
1585        let s = r#"Format: https://www.debian.org/doc/packaging-manuals/copyright-format/1.0/
1586
1587License: MIT
1588 Permission is hereby granted, free of charge, to any person obtaining a copy
1589 of this software and associated documentation files.
1590 .
1591 THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND.
1592"#;
1593        let copyright = s.parse::<super::Copyright>().expect("failed to parse");
1594        let license_para = copyright
1595            .iter_licenses()
1596            .next()
1597            .expect("no license paragraph");
1598        let text = license_para.text().expect("no license text");
1599
1600        // The period marker should be decoded to a blank line
1601        assert!(
1602            text.contains("\n\n"),
1603            "Expected blank line in decoded text, got: {:?}",
1604            text
1605        );
1606        assert!(
1607            !text.contains("\n.\n"),
1608            "Period marker should be decoded, not present in output"
1609        );
1610
1611        // Verify exact content
1612        let expected = "Permission is hereby granted, free of charge, to any person obtaining a copy\nof this software and associated documentation files.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND.";
1613        assert_eq!(text, expected);
1614    }
1615
1616    #[test]
1617    fn test_license_enum_decoding() {
1618        // Test that the license() method also decodes paragraph markers
1619        let s = r#"Format: https://www.debian.org/doc/packaging-manuals/copyright-format/1.0/
1620
1621License: GPL-3+
1622 This program is free software.
1623 .
1624 You can redistribute it.
1625"#;
1626        let copyright = s.parse::<super::Copyright>().expect("failed to parse");
1627        let license_para = copyright
1628            .iter_licenses()
1629            .next()
1630            .expect("no license paragraph");
1631        let license = license_para.license();
1632
1633        match license {
1634            crate::License::Named(name, text) => {
1635                assert_eq!(name, "GPL-3+");
1636                assert!(text.contains("\n\n"), "Expected blank line in decoded text");
1637                assert!(!text.contains("\n.\n"), "Period marker should be decoded");
1638                assert_eq!(
1639                    text,
1640                    "This program is free software.\n\nYou can redistribute it."
1641                );
1642            }
1643            _ => panic!("Expected Named license"),
1644        }
1645    }
1646
1647    #[test]
1648    fn test_encode_field_text() {
1649        // Test basic encoding of blank lines
1650        let input = "line 1\n\nline 3";
1651        let output = super::encode_field_text(input);
1652        assert_eq!(output, "line 1\n.\nline 3");
1653    }
1654
1655    #[test]
1656    fn test_encode_decode_round_trip() {
1657        // Test that encoding and decoding are inverse operations
1658        let original = "First paragraph\n\nSecond paragraph\n\nThird paragraph";
1659        let encoded = super::encode_field_text(original);
1660        let decoded = super::decode_field_text(&encoded);
1661        assert_eq!(
1662            decoded, original,
1663            "Round-trip encoding/decoding should preserve text"
1664        );
1665    }
1666
1667    #[test]
1668    fn test_set_license_with_blank_lines() {
1669        // Test that setting a license with blank lines encodes them properly
1670        let s = r#"Format: https://www.debian.org/doc/packaging-manuals/copyright-format/1.0/
1671
1672License: GPL-3+
1673 Original text
1674"#;
1675        let copyright = s.parse::<super::Copyright>().expect("failed to parse");
1676        let mut license_para = copyright
1677            .iter_licenses()
1678            .next()
1679            .expect("no license paragraph");
1680
1681        // Set license text with blank lines
1682        let new_license = crate::License::Named(
1683            "GPL-3+".to_string(),
1684            "First paragraph.\n\nSecond paragraph.".to_string(),
1685        );
1686        license_para.set_license(&new_license);
1687
1688        // Verify it was encoded properly in the raw deb822
1689        let raw_text = copyright.to_string();
1690        let expected_output = "Format: https://www.debian.org/doc/packaging-manuals/copyright-format/1.0/\n\nLicense: GPL-3+\n First paragraph.\n .\n Second paragraph.\n";
1691        assert_eq!(raw_text, expected_output);
1692
1693        // Verify it decodes back correctly
1694        let retrieved = license_para.text().expect("no text");
1695        assert_eq!(retrieved, "First paragraph.\n\nSecond paragraph.");
1696    }
1697
1698    #[test]
1699    fn test_set_text_with_blank_lines() {
1700        // Test that set_text also encodes blank lines
1701        let s = r#"Format: https://www.debian.org/doc/packaging-manuals/copyright-format/1.0/
1702
1703License: MIT
1704 Original text
1705"#;
1706        let copyright = s.parse::<super::Copyright>().expect("failed to parse");
1707        let mut license_para = copyright
1708            .iter_licenses()
1709            .next()
1710            .expect("no license paragraph");
1711
1712        // Set text with blank lines
1713        license_para.set_text(Some("Line 1\n\nLine 2"));
1714
1715        // Verify encoding
1716        let raw_text = copyright.to_string();
1717        let expected_output = "Format: https://www.debian.org/doc/packaging-manuals/copyright-format/1.0/\n\nLicense: MIT\n Line 1\n .\n Line 2\n";
1718        assert_eq!(raw_text, expected_output);
1719
1720        // Verify decoding
1721        let retrieved = license_para.text().expect("no text");
1722        assert_eq!(retrieved, "Line 1\n\nLine 2");
1723    }
1724
1725    #[test]
1726    fn test_set_license_uses_single_space_indent_for_new_multiline() {
1727        // Test that set_license() uses 1-space indentation when converting
1728        // a single-line license (no existing indentation) to multi-line
1729        let s = r#"Format: https://www.debian.org/doc/packaging-manuals/copyright-format/1.0/
1730
1731License: Apache-2.0
1732"#;
1733        let copyright = s.parse::<super::Copyright>().expect("failed to parse");
1734        let mut license_para = copyright
1735            .iter_licenses()
1736            .next()
1737            .expect("no license paragraph");
1738
1739        // Set new multi-line license text
1740        let new_license = crate::License::Named(
1741            "Apache-2.0".to_string(),
1742            "Licensed under the Apache License, Version 2.0".to_string(),
1743        );
1744        license_para.set_license(&new_license);
1745
1746        // Verify the new license uses 1-space indentation
1747        let result = copyright.to_string();
1748        let expected = "Format: https://www.debian.org/doc/packaging-manuals/copyright-format/1.0/\n\nLicense: Apache-2.0\n Licensed under the Apache License, Version 2.0\n";
1749        assert_eq!(result, expected);
1750    }
1751
1752    #[test]
1753    fn test_header_as_deb822() {
1754        let s = r#"Format: https://www.debian.org/doc/packaging-manuals/copyright-format/1.0/
1755Upstream-Name: foo
1756"#;
1757        let copyright = s.parse::<super::Copyright>().unwrap();
1758        let header = copyright.header().unwrap();
1759        let para = header.as_deb822();
1760        assert_eq!(para.get("Upstream-Name"), Some("foo".to_string()));
1761    }
1762
1763    #[test]
1764    fn test_files_paragraph_as_deb822() {
1765        let s = r#"Format: https://www.debian.org/doc/packaging-manuals/copyright-format/1.0/
1766
1767Files: *
1768Copyright: 2024 Test
1769License: MIT
1770"#;
1771        let copyright = s.parse::<super::Copyright>().unwrap();
1772        let files = copyright.iter_files().next().unwrap();
1773        let para = files.as_deb822();
1774        assert_eq!(para.get("Files"), Some("*".to_string()));
1775    }
1776
1777    #[test]
1778    fn test_license_paragraph_as_deb822() {
1779        let s = r#"Format: https://www.debian.org/doc/packaging-manuals/copyright-format/1.0/
1780
1781License: GPL-3+
1782 License text
1783"#;
1784        let copyright = s.parse::<super::Copyright>().unwrap();
1785        let license = copyright.iter_licenses().next().unwrap();
1786        let para = license.as_deb822();
1787        assert!(para.get("License").unwrap().starts_with("GPL-3+"));
1788    }
1789
1790    #[test]
1791    fn test_header_in_range() {
1792        let s = r#"Format: https://www.debian.org/doc/packaging-manuals/copyright-format/1.0/
1793Upstream-Name: example
1794
1795Files: *
1796Copyright: 2024 Author
1797License: MIT
1798"#;
1799        let copyright = s.parse::<super::Copyright>().expect("failed to parse");
1800
1801        // Get the header's text range
1802        let header = copyright.header().unwrap();
1803        let header_range = header.as_deb822().text_range();
1804
1805        // Query with the exact header range should return the header
1806        let result = copyright.header_in_range(header_range);
1807        assert!(result.is_some());
1808        assert_eq!(
1809            "https://www.debian.org/doc/packaging-manuals/copyright-format/1.0/",
1810            result.unwrap().format_string().unwrap()
1811        );
1812
1813        // Query with a range that overlaps with the header
1814        let overlapping_range =
1815            TextRange::new(TextSize::from(0), header_range.end() - TextSize::from(10));
1816        let result = copyright.header_in_range(overlapping_range);
1817        assert!(result.is_some());
1818
1819        // Query with a range completely outside the header should return None
1820        let files = copyright.iter_files().next().unwrap();
1821        let files_range = files.as_deb822().text_range();
1822        let result = copyright.header_in_range(files_range);
1823        assert!(result.is_none());
1824    }
1825
1826    #[test]
1827    fn test_iter_files_in_range() {
1828        let s = r#"Format: https://www.debian.org/doc/packaging-manuals/copyright-format/1.0/
1829
1830Files: *
1831Copyright: 2024 Main Author
1832License: GPL-3+
1833
1834Files: src/*
1835Copyright: 2024 Author
1836License: MIT
1837
1838Files: debian/*
1839Copyright: 2024 Debian Maintainer
1840License: GPL-3+
1841"#;
1842        let copyright = s.parse::<super::Copyright>().expect("failed to parse");
1843
1844        // Get all files paragraphs
1845        let all_files: Vec<_> = copyright.iter_files().collect();
1846        assert_eq!(3, all_files.len());
1847
1848        // Query with the range of the second Files paragraph
1849        let second_range = all_files[1].as_deb822().text_range();
1850        let result: Vec<_> = copyright.iter_files_in_range(second_range).collect();
1851        assert_eq!(1, result.len());
1852        assert_eq!(vec!["src/*"], result[0].files());
1853
1854        // Query with a range that spans the first two Files paragraphs
1855        let span_range = TextRange::new(
1856            all_files[0].as_deb822().text_range().start(),
1857            all_files[1].as_deb822().text_range().end(),
1858        );
1859        let result: Vec<_> = copyright.iter_files_in_range(span_range).collect();
1860        assert_eq!(2, result.len());
1861        assert_eq!(vec!["*"], result[0].files());
1862        assert_eq!(vec!["src/*"], result[1].files());
1863
1864        // Query with a range that doesn't overlap with any Files paragraphs
1865        let header_range = copyright.header().unwrap().as_deb822().text_range();
1866        let result: Vec<_> = copyright.iter_files_in_range(header_range).collect();
1867        assert_eq!(0, result.len());
1868    }
1869
1870    #[test]
1871    fn test_iter_licenses_in_range() {
1872        let s = r#"Format: https://www.debian.org/doc/packaging-manuals/copyright-format/1.0/
1873
1874Files: *
1875Copyright: 2024 Author
1876License: MIT
1877
1878License: MIT
1879 MIT license text here.
1880
1881License: GPL-3+
1882 GPL license text here.
1883"#;
1884        let copyright = s.parse::<super::Copyright>().expect("failed to parse");
1885
1886        // Get all license paragraphs
1887        let all_licenses: Vec<_> = copyright.iter_licenses().collect();
1888        assert_eq!(2, all_licenses.len());
1889
1890        // Query with the range of the first License paragraph
1891        let first_range = all_licenses[0].as_deb822().text_range();
1892        let result: Vec<_> = copyright.iter_licenses_in_range(first_range).collect();
1893        assert_eq!(1, result.len());
1894        assert_eq!(Some("MIT".to_string()), result[0].name());
1895
1896        // Query with a range that spans both License paragraphs
1897        let span_range = TextRange::new(
1898            all_licenses[0].as_deb822().text_range().start(),
1899            all_licenses[1].as_deb822().text_range().end(),
1900        );
1901        let result: Vec<_> = copyright.iter_licenses_in_range(span_range).collect();
1902        assert_eq!(2, result.len());
1903        assert_eq!(Some("MIT".to_string()), result[0].name());
1904        assert_eq!(Some("GPL-3+".to_string()), result[1].name());
1905
1906        // Query with a range that doesn't overlap with any License paragraphs (Files range)
1907        let files = copyright.iter_files().next().unwrap();
1908        let files_range = files.as_deb822().text_range();
1909        let result: Vec<_> = copyright.iter_licenses_in_range(files_range).collect();
1910        assert_eq!(0, result.len());
1911    }
1912
1913    #[test]
1914    fn test_header_wrap_and_sort() {
1915        // Test that Header::wrap_and_sort() properly orders fields
1916        let s = r#"Format: https://www.debian.org/doc/packaging-manuals/copyright-format/1.0/
1917Comment: Some comment
1918Source: https://example.com
1919Upstream-Contact: John Doe
1920Upstream-Name: example
1921"#;
1922        let copyright = s.parse::<super::Copyright>().expect("failed to parse");
1923        let mut header = copyright.header().unwrap();
1924
1925        header.wrap_and_sort(deb822_lossless::Indentation::Spaces(1), false, None);
1926
1927        // Verify the exact output with fields in HEADER_FIELD_ORDER
1928        let expected = "Format: https://www.debian.org/doc/packaging-manuals/copyright-format/1.0/\nUpstream-Name: example\nUpstream-Contact: John Doe\nSource: https://example.com\nComment: Some comment\n";
1929        assert_eq!(expected, header.0.to_string());
1930    }
1931
1932    #[test]
1933    fn test_files_paragraph_wrap_and_sort_field_order() {
1934        // Test that FilesParagraph::wrap_and_sort() properly orders fields
1935        let s = r#"Format: https://www.debian.org/doc/packaging-manuals/copyright-format/1.0/
1936
1937Comment: Some comment
1938License: MIT
1939Copyright: 2024 Author
1940Files: *
1941"#;
1942        let copyright = s.parse::<super::Copyright>().expect("failed to parse");
1943        let mut files = copyright.iter_files().next().unwrap();
1944
1945        files.wrap_and_sort(deb822_lossless::Indentation::Spaces(1), false, None);
1946
1947        // Verify the exact output with fields in FILES_FIELD_ORDER
1948        let expected = "Files: *\nCopyright: 2024 Author\nLicense: MIT\nComment: Some comment\n";
1949        assert_eq!(expected, files.0.to_string());
1950    }
1951
1952    #[test]
1953    fn test_files_paragraph_wrap_and_sort_patterns() {
1954        // Test that FilesParagraph::wrap_and_sort() properly sorts file patterns
1955        let s = r#"Format: https://www.debian.org/doc/packaging-manuals/copyright-format/1.0/
1956
1957Files: debian/* src/foo/* * src/*
1958Copyright: 2024 Author
1959License: MIT
1960"#;
1961        let copyright = s.parse::<super::Copyright>().expect("failed to parse");
1962        let mut files = copyright.iter_files().next().unwrap();
1963
1964        files.wrap_and_sort(deb822_lossless::Indentation::Spaces(1), false, None);
1965
1966        // Verify exact file pattern order
1967        assert_eq!(vec!["*", "src/*", "src/foo/*", "debian/*"], files.files());
1968
1969        // Verify exact output
1970        let expected = "Files: * src/* src/foo/* debian/*\nCopyright: 2024 Author\nLicense: MIT\n";
1971        assert_eq!(expected, files.0.to_string());
1972    }
1973
1974    #[test]
1975    fn test_license_paragraph_wrap_and_sort() {
1976        // Test that LicenseParagraph::wrap_and_sort() properly orders fields
1977        let s = r#"Format: https://www.debian.org/doc/packaging-manuals/copyright-format/1.0/
1978
1979Comment: This is a comment
1980License: GPL-3+
1981 GPL license text here.
1982"#;
1983        let copyright = s.parse::<super::Copyright>().expect("failed to parse");
1984        let mut license = copyright.iter_licenses().next().unwrap();
1985
1986        license.wrap_and_sort(deb822_lossless::Indentation::Spaces(1), false, None);
1987
1988        // Verify the exact output with fields in LICENSE_FIELD_ORDER
1989        let expected = "License: GPL-3+\n GPL license text here.\nComment: This is a comment\n";
1990        assert_eq!(expected, license.0.to_string());
1991    }
1992
1993    #[test]
1994    fn test_copyright_wrap_and_sort() {
1995        // Test that Copyright::wrap_and_sort() properly sorts paragraphs and file patterns
1996        let s = r#"Format: https://www.debian.org/doc/packaging-manuals/copyright-format/1.0/
1997Upstream-Name: example
1998
1999Files: debian/*
2000Copyright: 2024 Debian Maintainer
2001License: GPL-3+
2002
2003License: GPL-3+
2004 GPL license text here.
2005
2006Files: src/foo/* src/*
2007Copyright: 2024 Author
2008License: MIT
2009
2010Files: *
2011Copyright: 2024 Main Author
2012License: GPL-3+
2013
2014License: MIT
2015 MIT license text here.
2016"#;
2017        let mut copyright = s.parse::<super::Copyright>().expect("failed to parse");
2018
2019        // Apply wrap and sort
2020        copyright.wrap_and_sort(deb822_lossless::Indentation::Spaces(1), false, None);
2021
2022        // Verify exact output with correct paragraph and field ordering
2023        let expected = r#"Format: https://www.debian.org/doc/packaging-manuals/copyright-format/1.0/
2024Upstream-Name: example
2025
2026Files: *
2027Copyright: 2024 Main Author
2028License: GPL-3+
2029
2030Files: src/* src/foo/*
2031Copyright: 2024 Author
2032License: MIT
2033
2034Files: debian/*
2035Copyright: 2024 Debian Maintainer
2036License: GPL-3+
2037
2038License: GPL-3+
2039 GPL license text here.
2040
2041License: MIT
2042 MIT license text here.
2043"#;
2044        assert_eq!(expected, copyright.to_string());
2045
2046        // Also verify via iteration
2047        let files: Vec<_> = copyright.iter_files().collect();
2048        assert_eq!(3, files.len());
2049        assert_eq!(vec!["*"], files[0].files());
2050        assert_eq!(vec!["src/*", "src/foo/*"], files[1].files());
2051        assert_eq!(vec!["debian/*"], files[2].files());
2052
2053        let licenses: Vec<_> = copyright.iter_licenses().collect();
2054        assert_eq!(2, licenses.len());
2055    }
2056
2057    #[test]
2058    fn test_copyright_wrap_and_sort_file_patterns_within_paragraph() {
2059        // Test that file patterns within a Files paragraph are sorted correctly
2060        let s = r#"Format: https://www.debian.org/doc/packaging-manuals/copyright-format/1.0/
2061
2062Files: debian/* src/foo/* * src/*
2063Copyright: 2024 Author
2064License: MIT
2065"#;
2066        let mut copyright = s.parse::<super::Copyright>().expect("failed to parse");
2067
2068        copyright.wrap_and_sort(deb822_lossless::Indentation::Spaces(1), false, None);
2069
2070        // Verify exact output with sorted patterns
2071        let expected = r#"Format: https://www.debian.org/doc/packaging-manuals/copyright-format/1.0/
2072
2073Files: * src/* src/foo/* debian/*
2074Copyright: 2024 Author
2075License: MIT
2076"#;
2077        assert_eq!(expected, copyright.to_string());
2078
2079        // Also verify via iteration
2080        let files: Vec<_> = copyright.iter_files().collect();
2081        assert_eq!(1, files.len());
2082        assert_eq!(
2083            vec!["*", "src/*", "src/foo/*", "debian/*"],
2084            files[0].files()
2085        );
2086    }
2087
2088    #[test]
2089    fn test_set_license_normalizes_unusual_indentation() {
2090        // Regression test: set_license() should NOT preserve unusual indentation
2091        // from the original paragraph, it should always use 1-space indentation
2092        let s = r#"Format: https://www.debian.org/doc/packaging-manuals/copyright-format/1.0/
2093
2094License: Apache-2.0
2095                                 Apache License
2096                           Version 2.0, January 2004
2097                        http://www.apache.org/licenses/
2098 .
2099   TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
2100"#;
2101        let copyright = s.parse::<super::Copyright>().expect("failed to parse");
2102        let mut license_para = copyright
2103            .iter_licenses()
2104            .next()
2105            .expect("no license paragraph");
2106
2107        // Set new license text with normal formatting (no unusual indentation)
2108        let new_text = "Licensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\nhttp://www.apache.org/licenses/LICENSE-2.0";
2109        let new_license = crate::License::Named("Apache-2.0".to_string(), new_text.to_string());
2110        license_para.set_license(&new_license);
2111
2112        // Verify the output uses 1-space indentation, NOT the 33-space from the original
2113        let result = copyright.to_string();
2114
2115        // The bug is now fixed - output uses 1-space indentation regardless of the original formatting
2116        let expected = "Format: https://www.debian.org/doc/packaging-manuals/copyright-format/1.0/\n\nLicense: Apache-2.0\n Licensed under the Apache License, Version 2.0 (the \"License\");\n you may not use this file except in compliance with the License.\n You may obtain a copy of the License at\n .\n http://www.apache.org/licenses/LICENSE-2.0\n";
2117
2118        assert_eq!(result, expected);
2119    }
2120}
2121
2122/// Thread-safe parse result for Copyright files, suitable for use in Salsa databases.
2123///
2124/// This type wraps `deb822_lossless::Parse<Deb822>` for use in Salsa databases.
2125#[derive(Debug, Clone, PartialEq, Eq)]
2126pub struct Parse(deb822_lossless::Parse<Deb822>);
2127
2128impl From<deb822_lossless::Parse<Deb822>> for Parse {
2129    fn from(parse: deb822_lossless::Parse<Deb822>) -> Self {
2130        Parse(parse)
2131    }
2132}
2133
2134impl Parse {
2135    /// Parse copyright text, returning a Parse result
2136    pub fn parse(text: &str) -> Self {
2137        Parse(Deb822::parse(text))
2138    }
2139
2140    /// Parse copyright text relaxed (allows syntax errors)
2141    pub fn parse_relaxed(text: &str) -> Self {
2142        let deb822_parse = Deb822::parse(text);
2143        Parse(deb822_parse)
2144    }
2145
2146    /// Get the syntax errors
2147    pub fn errors(&self) -> &[String] {
2148        self.0.errors()
2149    }
2150
2151    /// Check if there are any errors
2152    pub fn ok(&self) -> bool {
2153        self.0.ok()
2154    }
2155
2156    /// Get the parsed tree, even if there are errors
2157    ///
2158    /// Returns the Copyright object regardless of parse errors, allowing
2159    /// error-resilient tooling to work with partial/invalid input.
2160    pub fn tree(&self) -> Copyright {
2161        Copyright(self.0.tree())
2162    }
2163
2164    /// Convert to a Copyright object
2165    pub fn to_copyright(&self) -> Copyright {
2166        if let Ok(deb822) = self.0.clone().to_result() {
2167            Copyright(deb822)
2168        } else {
2169            // If there are parse errors, create an empty copyright
2170            Copyright(Deb822::new())
2171        }
2172    }
2173
2174    /// Convert to a Result, returning the Copyright if there are no errors
2175    pub fn to_result(self) -> Result<Copyright, Error> {
2176        self.0.to_result().map(Copyright).map_err(Error::ParseError)
2177    }
2178}
2179
2180// Implement Send + Sync since deb822_lossless::Parse implements them
2181unsafe impl Send for Parse {}
2182unsafe impl Sync for Parse {}