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