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};
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    /// Returns the Files paragraph for the given filename.
163    ///
164    /// Consistent with the specification, this returns the last paragraph
165    /// that matches (which should be the most specific)
166    pub fn find_files(&self, filename: &Path) -> Option<FilesParagraph> {
167        self.iter_files().filter(|p| p.matches(filename)).last()
168    }
169
170    /// Find license by name
171    ///
172    /// This will return the first license paragraph that has the given name.
173    pub fn find_license_by_name(&self, name: &str) -> Option<License> {
174        self.iter_licenses()
175            .find(|p| p.name().as_deref() == Some(name))
176            .map(|x| x.into())
177    }
178
179    /// Returns the license for the given file.
180    pub fn find_license_for_file(&self, filename: &Path) -> Option<License> {
181        let files = self.find_files(filename)?;
182        let license = files.license()?;
183        if license.text().is_some() {
184            return Some(license);
185        }
186        self.find_license_by_name(license.name()?)
187    }
188
189    /// Read copyright file from a string, allowing syntax errors
190    pub fn from_str_relaxed(s: &str) -> Result<(Self, Vec<String>), Error> {
191        if !s.starts_with("Format:") {
192            return Err(Error::NotMachineReadable);
193        }
194
195        let (deb822, errors) = Deb822::from_str_relaxed(s);
196        Ok((Self(deb822), errors))
197    }
198
199    /// Read copyright file from a file, allowing syntax errors
200    pub fn from_file_relaxed<P: AsRef<Path>>(path: P) -> Result<(Self, Vec<String>), Error> {
201        let text = std::fs::read_to_string(path)?;
202        Self::from_str_relaxed(&text)
203    }
204
205    /// Read copyright file from a file
206    pub fn from_file<P: AsRef<Path>>(path: P) -> Result<Self, Error> {
207        let text = std::fs::read_to_string(path)?;
208        use std::str::FromStr;
209        Self::from_str(&text)
210    }
211
212    /// Add a new files paragraph
213    ///
214    /// Returns a mutable reference to the newly created FilesParagraph
215    pub fn add_files(
216        &mut self,
217        files: &[&str],
218        copyright: &[&str],
219        license: &License,
220    ) -> FilesParagraph {
221        let mut para = self.0.add_paragraph();
222        para.set_with_field_order("Files", &files.join(FILES_SEPARATOR), FILES_FIELD_ORDER);
223        para.set_with_field_order("Copyright", &copyright.join("\n"), FILES_FIELD_ORDER);
224        let license_text = match license {
225            License::Name(name) => name.to_string(),
226            License::Named(name, text) => format!("{}\n{}", name, text),
227            License::Text(text) => text.to_string(),
228        };
229        para.set_with_forced_indent(
230            "License",
231            &license_text,
232            &IndentPattern::Fixed(1),
233            Some(FILES_FIELD_ORDER),
234        );
235        FilesParagraph(para)
236    }
237
238    /// Add a new license paragraph
239    ///
240    /// Returns a mutable reference to the newly created LicenseParagraph
241    pub fn add_license(&mut self, license: &License) -> LicenseParagraph {
242        let mut para = self.0.add_paragraph();
243        let license_text = match license {
244            License::Name(name) => name.to_string(),
245            License::Named(name, text) => format!("{}\n{}", name, encode_field_text(text)),
246            License::Text(text) => encode_field_text(text),
247        };
248        // Force 1-space indentation for License field according to DEP-5 spec
249        para.set_with_indent_pattern(
250            "License",
251            &license_text,
252            Some(&IndentPattern::Fixed(1)),
253            Some(LICENSE_FIELD_ORDER),
254        );
255        LicenseParagraph(para)
256    }
257
258    /// Remove a license paragraph by its short name
259    ///
260    /// This removes the first standalone license paragraph that matches the given name.
261    /// Returns true if a paragraph was removed, false otherwise.
262    pub fn remove_license_by_name(&mut self, name: &str) -> bool {
263        // Find the index of the license paragraph
264        let mut index = None;
265        for (i, para) in self.0.paragraphs().enumerate() {
266            if !para.contains_key("Files")
267                && !para.contains_key("Format")
268                && para.contains_key("License")
269            {
270                let license_para = LicenseParagraph(para);
271                if license_para.name().as_deref() == Some(name) {
272                    index = Some(i);
273                    break;
274                }
275            }
276        }
277
278        if let Some(i) = index {
279            self.0.remove_paragraph(i);
280            true
281        } else {
282            false
283        }
284    }
285
286    /// Remove a files paragraph by matching file pattern
287    ///
288    /// This removes the first files paragraph where the Files field contains the given pattern.
289    /// Returns true if a paragraph was removed, false otherwise.
290    pub fn remove_files_by_pattern(&mut self, pattern: &str) -> bool {
291        // Find the index of the files paragraph
292        let mut index = None;
293        for (i, para) in self.0.paragraphs().enumerate() {
294            if para.contains_key("Files") {
295                let files_para = FilesParagraph(para);
296                if files_para.files().iter().any(|f| f == pattern) {
297                    index = Some(i);
298                    break;
299                }
300            }
301        }
302
303        if let Some(i) = index {
304            self.0.remove_paragraph(i);
305            true
306        } else {
307            false
308        }
309    }
310}
311
312/// Error parsing copyright files
313#[derive(Debug)]
314pub enum Error {
315    /// Parse error
316    ParseError(deb822_lossless::ParseError),
317
318    /// IO error
319    IoError(std::io::Error),
320
321    /// Invalid value (e.g., empty continuation lines)
322    InvalidValue(String),
323
324    /// The file is not machine readable
325    NotMachineReadable,
326}
327
328impl From<deb822_lossless::Error> for Error {
329    fn from(e: deb822_lossless::Error) -> Self {
330        match e {
331            deb822_lossless::Error::ParseError(e) => Error::ParseError(e),
332            deb822_lossless::Error::IoError(e) => Error::IoError(e),
333            deb822_lossless::Error::InvalidValue(msg) => Error::InvalidValue(msg),
334        }
335    }
336}
337
338impl From<std::io::Error> for Error {
339    fn from(e: std::io::Error) -> Self {
340        Error::IoError(e)
341    }
342}
343
344impl From<deb822_lossless::ParseError> for Error {
345    fn from(e: deb822_lossless::ParseError) -> Self {
346        Error::ParseError(e)
347    }
348}
349
350impl std::fmt::Display for Error {
351    fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
352        match &self {
353            Error::ParseError(e) => write!(f, "parse error: {}", e),
354            Error::NotMachineReadable => write!(f, "not machine readable"),
355            Error::IoError(e) => write!(f, "io error: {}", e),
356            Error::InvalidValue(msg) => write!(f, "invalid value: {}", msg),
357        }
358    }
359}
360
361impl std::error::Error for Error {}
362
363impl Default for Copyright {
364    fn default() -> Self {
365        Copyright(Deb822::new())
366    }
367}
368
369impl std::str::FromStr for Copyright {
370    type Err = Error;
371
372    fn from_str(s: &str) -> Result<Self, Self::Err> {
373        if !s.starts_with("Format:") {
374            return Err(Error::NotMachineReadable);
375        }
376        Ok(Self(Deb822::from_str(s)?))
377    }
378}
379
380impl std::fmt::Display for Copyright {
381    fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
382        f.write_str(&self.0.to_string())
383    }
384}
385
386/// A header paragraph
387pub struct Header(Paragraph);
388
389impl Header {
390    /// Returns the format string for this file.
391    pub fn format_string(&self) -> Option<String> {
392        self.0
393            .get("Format")
394            .or_else(|| self.0.get("Format-Specification"))
395    }
396
397    /// Return the underlying Deb822 paragraph
398    pub fn as_deb822(&self) -> &Paragraph {
399        &self.0
400    }
401
402    /// Return the underlying Deb822 paragraph, mutably
403    #[deprecated = "Use as_deb822 instead"]
404    pub fn as_mut_deb822(&mut self) -> &mut Paragraph {
405        &mut self.0
406    }
407
408    /// Upstream name
409    pub fn upstream_name(&self) -> Option<String> {
410        self.0.get("Upstream-Name")
411    }
412
413    /// Set the upstream name
414    pub fn set_upstream_name(&mut self, name: &str) {
415        self.0
416            .set_with_field_order("Upstream-Name", name, HEADER_FIELD_ORDER);
417    }
418
419    /// Upstream contact
420    pub fn upstream_contact(&self) -> Option<String> {
421        self.0.get("Upstream-Contact")
422    }
423
424    /// Set the upstream contact
425    pub fn set_upstream_contact(&mut self, contact: &str) {
426        self.0
427            .set_with_field_order("Upstream-Contact", contact, HEADER_FIELD_ORDER);
428    }
429
430    /// Source
431    pub fn source(&self) -> Option<String> {
432        self.0.get("Source")
433    }
434
435    /// Set the source
436    pub fn set_source(&mut self, source: &str) {
437        self.0
438            .set_with_field_order("Source", source, HEADER_FIELD_ORDER);
439    }
440
441    /// List of files excluded from the copyright information, as well as the source package
442    pub fn files_excluded(&self) -> Option<Vec<String>> {
443        self.0
444            .get("Files-Excluded")
445            .map(|x| x.split('\n').map(|x| x.to_string()).collect::<Vec<_>>())
446    }
447
448    /// Set excluded files
449    pub fn set_files_excluded(&mut self, files: &[&str]) {
450        self.0
451            .set_with_field_order("Files-Excluded", &files.join("\n"), HEADER_FIELD_ORDER);
452    }
453
454    /// Fix the the header paragraph
455    ///
456    /// Currently this just renames `Format-Specification` to `Format` and replaces older format
457    /// strings with the current format string.
458    pub fn fix(&mut self) {
459        if self.0.contains_key("Format-Specification") {
460            self.0.rename("Format-Specification", "Format");
461        }
462
463        if let Some(mut format) = self.0.get("Format") {
464            if !format.ends_with('/') {
465                format.push('/');
466            }
467
468            if let Some(rest) = format.strip_prefix("http:") {
469                format = format!("https:{}", rest);
470            }
471
472            if KNOWN_FORMATS.contains(&format.as_str()) {
473                format = CURRENT_FORMAT.to_string();
474            }
475
476            self.0.set("Format", format.as_str());
477        }
478    }
479}
480
481/// A files paragraph
482pub struct FilesParagraph(Paragraph);
483
484impl FilesParagraph {
485    /// Return the underlying Deb822 paragraph
486    pub fn as_deb822(&self) -> &Paragraph {
487        &self.0
488    }
489
490    /// List of file patterns in the paragraph
491    pub fn files(&self) -> Vec<String> {
492        self.0
493            .get("Files")
494            .unwrap()
495            .split_whitespace()
496            .map(|v| v.to_string())
497            .collect::<Vec<_>>()
498    }
499
500    /// Set the file patterns in the paragraph
501    pub fn set_files(&mut self, files: &[&str]) {
502        self.0
503            .set_with_field_order("Files", &files.join(FILES_SEPARATOR), FILES_FIELD_ORDER);
504    }
505
506    /// Add a file pattern to the paragraph
507    ///
508    /// If the pattern already exists, it will not be added again.
509    pub fn add_file(&mut self, pattern: &str) {
510        let mut files = self.files();
511        if !files.contains(&pattern.to_string()) {
512            files.push(pattern.to_string());
513            self.0
514                .set_with_field_order("Files", &files.join(FILES_SEPARATOR), FILES_FIELD_ORDER);
515        }
516    }
517
518    /// Remove a file pattern from the paragraph
519    ///
520    /// Returns true if the pattern was found and removed, false otherwise.
521    pub fn remove_file(&mut self, pattern: &str) -> bool {
522        let mut files = self.files();
523        if let Some(pos) = files.iter().position(|f| f == pattern) {
524            files.remove(pos);
525            self.0
526                .set_with_field_order("Files", &files.join(FILES_SEPARATOR), FILES_FIELD_ORDER);
527            true
528        } else {
529            false
530        }
531    }
532
533    /// Check whether the paragraph matches the given filename
534    pub fn matches(&self, filename: &std::path::Path) -> bool {
535        self.files()
536            .iter()
537            .any(|f| crate::glob::glob_to_regex(f).is_match(filename.to_str().unwrap()))
538    }
539
540    /// Copyright holders in the paragraph
541    pub fn copyright(&self) -> Vec<String> {
542        self.0
543            .get("Copyright")
544            .unwrap_or_default()
545            .split('\n')
546            .map(|x| x.to_string())
547            .collect::<Vec<_>>()
548    }
549
550    /// Set the copyright
551    pub fn set_copyright(&mut self, authors: &[&str]) {
552        self.0
553            .set_with_field_order("Copyright", &authors.join("\n"), FILES_FIELD_ORDER);
554    }
555
556    /// Comment associated with the files paragraph
557    pub fn comment(&self) -> Option<String> {
558        self.0.get("Comment")
559    }
560
561    /// Set the comment associated with the files paragraph
562    pub fn set_comment(&mut self, comment: &str) {
563        self.0
564            .set_with_field_order("Comment", comment, FILES_FIELD_ORDER);
565    }
566
567    /// License in the paragraph
568    pub fn license(&self) -> Option<License> {
569        self.0.get_multiline("License").map(|x| {
570            x.split_once('\n').map_or_else(
571                || License::Name(x.to_string()),
572                |(name, text)| {
573                    if name.is_empty() {
574                        License::Text(text.to_string())
575                    } else {
576                        License::Named(name.to_string(), text.to_string())
577                    }
578                },
579            )
580        })
581    }
582
583    /// Set the license associated with the files paragraph
584    pub fn set_license(&mut self, license: &License) {
585        let text = match license {
586            License::Name(name) => name.to_string(),
587            License::Named(name, text) => format!("{}\n{}", name, encode_field_text(text)),
588            License::Text(text) => encode_field_text(text),
589        };
590        // Force 1-space indentation for License field according to DEP-5 spec
591        let indent_pattern = deb822_lossless::IndentPattern::Fixed(1);
592        self.0
593            .set_with_forced_indent("License", &text, &indent_pattern, Some(FILES_FIELD_ORDER));
594    }
595}
596
597/// A paragraph that contains a license
598pub struct LicenseParagraph(Paragraph);
599
600impl From<LicenseParagraph> for License {
601    fn from(p: LicenseParagraph) -> Self {
602        let x = p.0.get_multiline("License").unwrap();
603        x.split_once('\n').map_or_else(
604            || License::Name(x.to_string()),
605            |(name, text)| {
606                if name.is_empty() {
607                    License::Text(text.to_string())
608                } else {
609                    License::Named(name.to_string(), text.to_string())
610                }
611            },
612        )
613    }
614}
615
616impl LicenseParagraph {
617    /// Return the underlying Deb822 paragraph
618    pub fn as_deb822(&self) -> &Paragraph {
619        &self.0
620    }
621
622    /// Comment associated with the license
623    pub fn comment(&self) -> Option<String> {
624        self.0.get("Comment")
625    }
626
627    /// Set the comment associated with the license
628    pub fn set_comment(&mut self, comment: &str) {
629        self.0
630            .set_with_field_order("Comment", comment, LICENSE_FIELD_ORDER);
631    }
632
633    /// Name of the license
634    pub fn name(&self) -> Option<String> {
635        self.0
636            .get_multiline("License")
637            .and_then(|x| x.split_once('\n').map(|(name, _)| name.to_string()))
638    }
639
640    /// Text of the license
641    pub fn text(&self) -> Option<String> {
642        self.0
643            .get_multiline("License")
644            .and_then(|x| x.split_once('\n').map(|(_, text)| decode_field_text(text)))
645    }
646
647    /// Get the license as a License enum
648    pub fn license(&self) -> License {
649        let x = self.0.get_multiline("License").unwrap();
650        x.split_once('\n').map_or_else(
651            || License::Name(x.to_string()),
652            |(name, text)| {
653                let decoded_text = decode_field_text(text);
654                if name.is_empty() {
655                    License::Text(decoded_text)
656                } else {
657                    License::Named(name.to_string(), decoded_text)
658                }
659            },
660        )
661    }
662
663    /// Set the license
664    pub fn set_license(&mut self, license: &License) {
665        let text = match license {
666            License::Name(name) => name.to_string(),
667            License::Named(name, text) => format!("{}\n{}", name, encode_field_text(text)),
668            License::Text(text) => encode_field_text(text),
669        };
670        // Force 1-space indentation for License field according to DEP-5 spec
671        let indent_pattern = deb822_lossless::IndentPattern::Fixed(1);
672        self.0
673            .set_with_forced_indent("License", &text, &indent_pattern, Some(LICENSE_FIELD_ORDER));
674    }
675
676    /// Set just the license name (short name on the first line)
677    ///
678    /// If the license currently has text, it will be preserved.
679    /// If the license has no text, this will set it to just a name.
680    pub fn set_name(&mut self, name: &str) {
681        let current = self.license();
682        let new_license = match current {
683            License::Named(_, text) | License::Text(text) => License::Named(name.to_string(), text),
684            License::Name(_) => License::Name(name.to_string()),
685        };
686        self.set_license(&new_license);
687    }
688
689    /// Set just the license text (the full license text after the first line)
690    ///
691    /// If text is None, removes the license text while keeping the name.
692    /// If the license currently has a name, it will be preserved.
693    /// If the license has no name and text is Some, this will create a license with just text.
694    pub fn set_text(&mut self, text: Option<&str>) {
695        let current = self.license();
696        let new_license = match (current, text) {
697            (License::Named(name, _), Some(new_text)) | (License::Name(name), Some(new_text)) => {
698                License::Named(name, new_text.to_string())
699            }
700            (License::Named(name, _), None) | (License::Name(name), None) => License::Name(name),
701            (License::Text(_), Some(new_text)) => License::Text(new_text.to_string()),
702            (License::Text(_), None) => {
703                // Edge case: removing text from a text-only license. Set empty name.
704                License::Name(String::new())
705            }
706        };
707        self.set_license(&new_license);
708    }
709}
710
711#[cfg(test)]
712mod tests {
713    #[test]
714    fn test_not_machine_readable() {
715        let s = r#"
716This copyright file is not machine readable.
717"#;
718        let ret = s.parse::<super::Copyright>();
719        assert!(ret.is_err());
720        assert!(matches!(ret.unwrap_err(), super::Error::NotMachineReadable));
721    }
722
723    #[test]
724    fn test_new() {
725        let n = super::Copyright::new();
726        assert_eq!(
727            n.to_string().as_str(),
728            "Format: https://www.debian.org/doc/packaging-manuals/copyright-format/1.0/\n"
729        );
730    }
731
732    #[test]
733    fn test_parse() {
734        let s = r#"Format: https://www.debian.org/doc/packaging-manuals/copyright-format/1.0/
735Upstream-Name: foo
736Upstream-Contact: Joe Bloggs <joe@example.com>
737Source: https://example.com/foo
738
739Files: *
740Copyright:
741  2020 Joe Bloggs <joe@example.com>
742License: GPL-3+
743
744Files: debian/*
745Comment: Debian packaging is licensed under the GPL-3+.
746Copyright: 2023 Jelmer Vernooij
747License: GPL-3+
748
749License: GPL-3+
750 This program is free software: you can redistribute it and/or modify
751 it under the terms of the GNU General Public License as published by
752 the Free Software Foundation, either version 3 of the License, or
753 (at your option) any later version.
754"#;
755        let copyright = s.parse::<super::Copyright>().expect("failed to parse");
756
757        assert_eq!(
758            "https://www.debian.org/doc/packaging-manuals/copyright-format/1.0/",
759            copyright.header().unwrap().format_string().unwrap()
760        );
761        assert_eq!("foo", copyright.header().unwrap().upstream_name().unwrap());
762        assert_eq!(
763            "Joe Bloggs <joe@example.com>",
764            copyright.header().unwrap().upstream_contact().unwrap()
765        );
766        assert_eq!(
767            "https://example.com/foo",
768            copyright.header().unwrap().source().unwrap()
769        );
770
771        let files = copyright.iter_files().collect::<Vec<_>>();
772        assert_eq!(2, files.len());
773        assert_eq!("*", files[0].files().join(" "));
774        assert_eq!("debian/*", files[1].files().join(" "));
775        assert_eq!(
776            "Debian packaging is licensed under the GPL-3+.",
777            files[1].comment().unwrap()
778        );
779        assert_eq!(
780            vec!["2023 Jelmer Vernooij".to_string()],
781            files[1].copyright()
782        );
783        assert_eq!("GPL-3+", files[1].license().unwrap().name().unwrap());
784        assert_eq!(files[1].license().unwrap().text(), None);
785
786        let licenses = copyright.iter_licenses().collect::<Vec<_>>();
787        assert_eq!(1, licenses.len());
788        assert_eq!("GPL-3+", licenses[0].name().unwrap());
789        assert_eq!(
790            "This program is free software: you can redistribute it and/or modify
791it under the terms of the GNU General Public License as published by
792the Free Software Foundation, either version 3 of the License, or
793(at your option) any later version.",
794            licenses[0].text().unwrap()
795        );
796
797        let upstream_files = copyright.find_files(std::path::Path::new("foo.c")).unwrap();
798        assert_eq!(vec!["*"], upstream_files.files());
799
800        let debian_files = copyright
801            .find_files(std::path::Path::new("debian/foo.c"))
802            .unwrap();
803        assert_eq!(vec!["debian/*"], debian_files.files());
804
805        let gpl = copyright.find_license_by_name("GPL-3+");
806        assert!(gpl.is_some());
807
808        let gpl = copyright.find_license_for_file(std::path::Path::new("debian/foo.c"));
809        assert_eq!(gpl.unwrap().name().unwrap(), "GPL-3+");
810    }
811
812    #[test]
813    fn test_from_str_relaxed() {
814        let s = r#"Format: https://www.debian.org/doc/packaging-manuals/copyright-format/1.0/
815Upstream-Name: foo
816Source: https://example.com/foo
817
818Files: *
819Copyright: 2020 Joe Bloggs <joe@example.com>
820License: GPL-3+
821"#;
822        let (copyright, errors) = super::Copyright::from_str_relaxed(s).unwrap();
823        assert!(errors.is_empty());
824        assert_eq!("foo", copyright.header().unwrap().upstream_name().unwrap());
825    }
826
827    #[test]
828    fn test_from_file_relaxed() {
829        let tmpfile = std::env::temp_dir().join("test_copyright.txt");
830        std::fs::write(
831            &tmpfile,
832            r#"Format: https://www.debian.org/doc/packaging-manuals/copyright-format/1.0/
833Upstream-Name: foo
834Source: https://example.com/foo
835
836Files: *
837Copyright: 2020 Joe Bloggs <joe@example.com>
838License: GPL-3+
839"#,
840        )
841        .unwrap();
842        let (copyright, errors) = super::Copyright::from_file_relaxed(&tmpfile).unwrap();
843        assert!(errors.is_empty());
844        assert_eq!("foo", copyright.header().unwrap().upstream_name().unwrap());
845        std::fs::remove_file(&tmpfile).unwrap();
846    }
847
848    #[test]
849    fn test_header_set_upstream_contact() {
850        let copyright = super::Copyright::new();
851        let mut header = copyright.header().unwrap();
852        header.set_upstream_contact("Test Person <test@example.com>");
853        assert_eq!(
854            header.upstream_contact().unwrap(),
855            "Test Person <test@example.com>"
856        );
857    }
858
859    #[test]
860    fn test_header_set_source() {
861        let copyright = super::Copyright::new();
862        let mut header = copyright.header().unwrap();
863        header.set_source("https://example.com/source");
864        assert_eq!(header.source().unwrap(), "https://example.com/source");
865    }
866
867    #[test]
868    fn test_license_paragraph_set_comment() {
869        let s = r#"Format: https://www.debian.org/doc/packaging-manuals/copyright-format/1.0/
870
871License: GPL-3+
872 This is the license text.
873"#;
874        let copyright = s.parse::<super::Copyright>().unwrap();
875        let mut license = copyright.iter_licenses().next().unwrap();
876        license.set_comment("This is a test comment");
877        assert_eq!(license.comment().unwrap(), "This is a test comment");
878    }
879
880    #[test]
881    fn test_license_paragraph_set_license() {
882        let s = r#"Format: https://www.debian.org/doc/packaging-manuals/copyright-format/1.0/
883
884License: GPL-3+
885 Old license text.
886"#;
887        let copyright = s.parse::<super::Copyright>().unwrap();
888        let mut license = copyright.iter_licenses().next().unwrap();
889
890        let new_license = crate::License::Named(
891            "MIT".to_string(),
892            "Permission is hereby granted...".to_string(),
893        );
894        license.set_license(&new_license);
895
896        assert_eq!(license.name().unwrap(), "MIT");
897        assert_eq!(license.text().unwrap(), "Permission is hereby granted...");
898    }
899
900    #[test]
901    fn test_iter_licenses_excludes_header() {
902        // Test that iter_licenses does not include the header paragraph even if it has a License field
903        let s = r#"Format: https://www.debian.org/doc/packaging-manuals/copyright-format/1.0/
904Upstream-Name: foo
905License: GPL-3+
906
907Files: *
908Copyright: 2020 Joe Bloggs
909License: MIT
910
911License: GPL-3+
912 This is the GPL-3+ license text.
913"#;
914        let copyright = s.parse::<super::Copyright>().unwrap();
915        let licenses: Vec<_> = copyright.iter_licenses().collect();
916
917        // Should only have the standalone License paragraph, not the header
918        assert_eq!(1, licenses.len());
919        assert_eq!("GPL-3+", licenses[0].name().unwrap());
920        assert_eq!(
921            "This is the GPL-3+ license text.",
922            licenses[0].text().unwrap()
923        );
924    }
925
926    #[test]
927    fn test_add_files() {
928        let mut copyright = super::Copyright::new();
929        let license = crate::License::Name("GPL-3+".to_string());
930        copyright.add_files(
931            &["src/*", "*.rs"],
932            &["2024 John Doe", "2024 Jane Doe"],
933            &license,
934        );
935
936        let files: Vec<_> = copyright.iter_files().collect();
937        assert_eq!(1, files.len());
938        assert_eq!(vec!["src/*", "*.rs"], files[0].files());
939        assert_eq!(vec!["2024 John Doe", "2024 Jane Doe"], files[0].copyright());
940        assert_eq!("GPL-3+", files[0].license().unwrap().name().unwrap());
941
942        // Verify the generated format
943        assert_eq!(
944            copyright.to_string(),
945            "Format: https://www.debian.org/doc/packaging-manuals/copyright-format/1.0/\n\n\
946             Files: src/* *.rs\n\
947             Copyright: 2024 John Doe\n           2024 Jane Doe\n\
948             License: GPL-3+\n"
949        );
950    }
951
952    #[test]
953    fn test_add_files_with_license_text() {
954        let mut copyright = super::Copyright::new();
955        let license = crate::License::Named(
956            "MIT".to_string(),
957            "Permission is hereby granted...".to_string(),
958        );
959        copyright.add_files(&["*"], &["2024 Test Author"], &license);
960
961        let files: Vec<_> = copyright.iter_files().collect();
962        assert_eq!(1, files.len());
963        assert_eq!("MIT", files[0].license().unwrap().name().unwrap());
964        assert_eq!(
965            "Permission is hereby granted...",
966            files[0].license().unwrap().text().unwrap()
967        );
968
969        // Verify the generated format
970        assert_eq!(
971            copyright.to_string(),
972            "Format: https://www.debian.org/doc/packaging-manuals/copyright-format/1.0/\n\n\
973             Files: *\n\
974             Copyright: 2024 Test Author\n\
975             License: MIT\n Permission is hereby granted...\n"
976        );
977    }
978
979    #[test]
980    fn test_add_license() {
981        let mut copyright = super::Copyright::new();
982        let license = crate::License::Named(
983            "GPL-3+".to_string(),
984            "This is the GPL-3+ license text.".to_string(),
985        );
986        copyright.add_license(&license);
987
988        let licenses: Vec<_> = copyright.iter_licenses().collect();
989        assert_eq!(1, licenses.len());
990        assert_eq!("GPL-3+", licenses[0].name().unwrap());
991        assert_eq!(
992            "This is the GPL-3+ license text.",
993            licenses[0].text().unwrap()
994        );
995
996        // Verify the generated format
997        assert_eq!(
998            copyright.to_string(),
999            "Format: https://www.debian.org/doc/packaging-manuals/copyright-format/1.0/\n\n\
1000             License: GPL-3+\n This is the GPL-3+ license text.\n"
1001        );
1002    }
1003
1004    #[test]
1005    fn test_add_multiple_paragraphs() {
1006        let mut copyright = super::Copyright::new();
1007
1008        // Add a files paragraph
1009        let license1 = crate::License::Name("MIT".to_string());
1010        copyright.add_files(&["src/*"], &["2024 Author One"], &license1);
1011
1012        // Add another files paragraph
1013        let license2 = crate::License::Name("GPL-3+".to_string());
1014        copyright.add_files(&["debian/*"], &["2024 Author Two"], &license2);
1015
1016        // Add a license paragraph
1017        let license3 =
1018            crate::License::Named("GPL-3+".to_string(), "Full GPL-3+ text here.".to_string());
1019        copyright.add_license(&license3);
1020
1021        // Verify all paragraphs were added
1022        assert_eq!(2, copyright.iter_files().count());
1023        assert_eq!(1, copyright.iter_licenses().count());
1024
1025        let files: Vec<_> = copyright.iter_files().collect();
1026        assert_eq!(vec!["src/*"], files[0].files());
1027        assert_eq!(vec!["debian/*"], files[1].files());
1028
1029        let licenses: Vec<_> = copyright.iter_licenses().collect();
1030        assert_eq!("GPL-3+", licenses[0].name().unwrap());
1031        assert_eq!("Full GPL-3+ text here.", licenses[0].text().unwrap());
1032
1033        // Verify the generated format
1034        assert_eq!(
1035            copyright.to_string(),
1036            "Format: https://www.debian.org/doc/packaging-manuals/copyright-format/1.0/\n\n\
1037             Files: src/*\n\
1038             Copyright: 2024 Author One\n\
1039             License: MIT\n\n\
1040             Files: debian/*\n\
1041             Copyright: 2024 Author Two\n\
1042             License: GPL-3+\n\n\
1043             License: GPL-3+\n Full GPL-3+ text here.\n"
1044        );
1045    }
1046
1047    #[test]
1048    fn test_remove_license_by_name() {
1049        let mut copyright = super::Copyright::new();
1050
1051        // Add multiple license paragraphs
1052        let license1 = crate::License::Named("MIT".to_string(), "MIT license text.".to_string());
1053        copyright.add_license(&license1);
1054
1055        let license2 =
1056            crate::License::Named("GPL-3+".to_string(), "GPL-3+ license text.".to_string());
1057        copyright.add_license(&license2);
1058
1059        let license3 =
1060            crate::License::Named("Apache-2.0".to_string(), "Apache license text.".to_string());
1061        copyright.add_license(&license3);
1062
1063        // Verify we have 3 license paragraphs
1064        assert_eq!(3, copyright.iter_licenses().count());
1065
1066        // Remove the GPL-3+ license
1067        let removed = copyright.remove_license_by_name("GPL-3+");
1068        assert!(removed);
1069
1070        // Verify we now have 2 license paragraphs
1071        assert_eq!(2, copyright.iter_licenses().count());
1072
1073        // Verify the remaining licenses
1074        let licenses: Vec<_> = copyright.iter_licenses().collect();
1075        assert_eq!("MIT", licenses[0].name().unwrap());
1076        assert_eq!("Apache-2.0", licenses[1].name().unwrap());
1077
1078        // Try to remove a non-existent license
1079        let removed = copyright.remove_license_by_name("BSD-3-Clause");
1080        assert!(!removed);
1081        assert_eq!(2, copyright.iter_licenses().count());
1082    }
1083
1084    #[test]
1085    fn test_remove_files_by_pattern() {
1086        let mut copyright = super::Copyright::new();
1087
1088        // Add multiple files paragraphs
1089        let license1 = crate::License::Name("MIT".to_string());
1090        copyright.add_files(&["src/*"], &["2024 Author One"], &license1);
1091
1092        let license2 = crate::License::Name("GPL-3+".to_string());
1093        copyright.add_files(&["debian/*"], &["2024 Author Two"], &license2);
1094
1095        let license3 = crate::License::Name("Apache-2.0".to_string());
1096        copyright.add_files(&["docs/*"], &["2024 Author Three"], &license3);
1097
1098        // Verify we have 3 files paragraphs
1099        assert_eq!(3, copyright.iter_files().count());
1100
1101        // Remove the debian/* files paragraph
1102        let removed = copyright.remove_files_by_pattern("debian/*");
1103        assert!(removed);
1104
1105        // Verify we now have 2 files paragraphs
1106        assert_eq!(2, copyright.iter_files().count());
1107
1108        // Verify the remaining files paragraphs
1109        let files: Vec<_> = copyright.iter_files().collect();
1110        assert_eq!(vec!["src/*"], files[0].files());
1111        assert_eq!(vec!["docs/*"], files[1].files());
1112
1113        // Try to remove a non-existent pattern
1114        let removed = copyright.remove_files_by_pattern("tests/*");
1115        assert!(!removed);
1116        assert_eq!(2, copyright.iter_files().count());
1117    }
1118
1119    #[test]
1120    fn test_remove_files_by_pattern_with_multiple_patterns() {
1121        let mut copyright = super::Copyright::new();
1122
1123        // Add a files paragraph with multiple patterns
1124        let license = crate::License::Name("MIT".to_string());
1125        copyright.add_files(&["src/*", "*.rs"], &["2024 Author"], &license);
1126
1127        // Verify we have 1 files paragraph
1128        assert_eq!(1, copyright.iter_files().count());
1129
1130        // Remove by matching one of the patterns
1131        let removed = copyright.remove_files_by_pattern("*.rs");
1132        assert!(removed);
1133
1134        // Verify the paragraph was removed
1135        assert_eq!(0, copyright.iter_files().count());
1136    }
1137
1138    #[test]
1139    fn test_license_paragraph_set_name() {
1140        let s = r#"Format: https://www.debian.org/doc/packaging-manuals/copyright-format/1.0/
1141
1142License: GPL-3+
1143 This is the GPL-3+ license text.
1144"#;
1145        let copyright = s.parse::<super::Copyright>().unwrap();
1146        let mut license = copyright.iter_licenses().next().unwrap();
1147
1148        // Change just the name, preserving the text
1149        license.set_name("Apache-2.0");
1150
1151        assert_eq!(license.name().unwrap(), "Apache-2.0");
1152        assert_eq!(license.text().unwrap(), "This is the GPL-3+ license text.");
1153    }
1154
1155    #[test]
1156    fn test_license_paragraph_set_name_no_text() {
1157        let s = r#"Format: https://www.debian.org/doc/packaging-manuals/copyright-format/1.0/
1158
1159License: GPL-3+
1160"#;
1161        let copyright = s.parse::<super::Copyright>().unwrap();
1162        let mut license = copyright.iter_licenses().next().unwrap();
1163
1164        // Change just the name when there's no text
1165        license.set_name("MIT");
1166
1167        assert_eq!(license.license(), crate::License::Name("MIT".to_string()));
1168        assert_eq!(license.text(), None);
1169    }
1170
1171    #[test]
1172    fn test_license_paragraph_set_text() {
1173        let s = r#"Format: https://www.debian.org/doc/packaging-manuals/copyright-format/1.0/
1174
1175License: GPL-3+
1176 Old license text.
1177"#;
1178        let copyright = s.parse::<super::Copyright>().unwrap();
1179        let mut license = copyright.iter_licenses().next().unwrap();
1180
1181        // Change just the text, preserving the name
1182        license.set_text(Some("New license text."));
1183
1184        assert_eq!(license.name().unwrap(), "GPL-3+");
1185        assert_eq!(license.text().unwrap(), "New license text.");
1186    }
1187
1188    #[test]
1189    fn test_license_paragraph_set_text_remove() {
1190        let s = r#"Format: https://www.debian.org/doc/packaging-manuals/copyright-format/1.0/
1191
1192License: GPL-3+
1193 Old license text.
1194"#;
1195        let copyright = s.parse::<super::Copyright>().unwrap();
1196        let mut license = copyright.iter_licenses().next().unwrap();
1197
1198        // Remove the text, keeping just the name
1199        license.set_text(None);
1200
1201        assert_eq!(
1202            license.license(),
1203            crate::License::Name("GPL-3+".to_string())
1204        );
1205        assert_eq!(license.text(), None);
1206    }
1207
1208    #[test]
1209    fn test_license_paragraph_set_text_add() {
1210        let s = r#"Format: https://www.debian.org/doc/packaging-manuals/copyright-format/1.0/
1211
1212License: GPL-3+
1213"#;
1214        let copyright = s.parse::<super::Copyright>().unwrap();
1215        let mut license = copyright.iter_licenses().next().unwrap();
1216
1217        // Add text to a name-only license
1218        license.set_text(Some("This is the full GPL-3+ license text."));
1219
1220        assert_eq!(license.name().unwrap(), "GPL-3+");
1221        assert_eq!(
1222            license.text().unwrap(),
1223            "This is the full GPL-3+ license text."
1224        );
1225    }
1226
1227    #[test]
1228    fn test_files_paragraph_set_files() {
1229        let s = r#"Format: https://www.debian.org/doc/packaging-manuals/copyright-format/1.0/
1230
1231Files: *
1232Copyright: 2024 Test Author
1233License: MIT
1234"#;
1235        let copyright = s.parse::<super::Copyright>().unwrap();
1236        let mut files = copyright.iter_files().next().unwrap();
1237
1238        // Set new file patterns
1239        files.set_files(&["src/*", "*.rs", "tests/*"]);
1240
1241        // Verify the files were updated
1242        assert_eq!(vec!["src/*", "*.rs", "tests/*"], files.files());
1243    }
1244
1245    #[test]
1246    fn test_files_paragraph_add_file() {
1247        let s = r#"Format: https://www.debian.org/doc/packaging-manuals/copyright-format/1.0/
1248
1249Files: src/*
1250Copyright: 2024 Test Author
1251License: MIT
1252"#;
1253        let copyright = s.parse::<super::Copyright>().unwrap();
1254        let mut files = copyright.iter_files().next().unwrap();
1255
1256        // Add a new file pattern
1257        files.add_file("*.rs");
1258        assert_eq!(vec!["src/*", "*.rs"], files.files());
1259
1260        // Add another pattern
1261        files.add_file("tests/*");
1262        assert_eq!(vec!["src/*", "*.rs", "tests/*"], files.files());
1263
1264        // Try to add a duplicate - should not be added
1265        files.add_file("*.rs");
1266        assert_eq!(vec!["src/*", "*.rs", "tests/*"], files.files());
1267    }
1268
1269    #[test]
1270    fn test_files_paragraph_remove_file() {
1271        let s = r#"Format: https://www.debian.org/doc/packaging-manuals/copyright-format/1.0/
1272
1273Files: src/* *.rs tests/*
1274Copyright: 2024 Test Author
1275License: MIT
1276"#;
1277        let copyright = s.parse::<super::Copyright>().unwrap();
1278        let mut files = copyright.iter_files().next().unwrap();
1279
1280        // Remove a file pattern
1281        let removed = files.remove_file("*.rs");
1282        assert!(removed);
1283        assert_eq!(vec!["src/*", "tests/*"], files.files());
1284
1285        // Remove another pattern
1286        let removed = files.remove_file("tests/*");
1287        assert!(removed);
1288        assert_eq!(vec!["src/*"], files.files());
1289
1290        // Try to remove a non-existent pattern
1291        let removed = files.remove_file("debian/*");
1292        assert!(!removed);
1293        assert_eq!(vec!["src/*"], files.files());
1294    }
1295
1296    #[test]
1297    fn test_field_order_with_comment() {
1298        // Test that fields follow DEP-5 order: Files, Copyright, License, Comment
1299        let mut copyright = super::Copyright::new();
1300
1301        let files = vec!["*"];
1302        let copyrights = vec!["Unknown"];
1303        let license = crate::License::Name("GPL-2+".to_string());
1304
1305        let mut para = copyright.add_files(&files, &copyrights, &license);
1306        para.set_comment("Test comment");
1307
1308        let output = copyright.to_string();
1309
1310        // Expected order: Format, blank line, Files, Copyright, License, Comment
1311        let expected =
1312            "Format: https://www.debian.org/doc/packaging-manuals/copyright-format/1.0/\n\n\
1313                        Files: *\n\
1314                        Copyright: Unknown\n\
1315                        License: GPL-2+\n\
1316                        Comment: Test comment\n";
1317
1318        assert_eq!(
1319            output, expected,
1320            "Fields should be in DEP-5 order (Files, Copyright, License, Comment), but got:\n{}",
1321            output
1322        );
1323    }
1324
1325    #[test]
1326    fn test_license_text_decoding_paragraph_markers() {
1327        // Test that paragraph markers (.) are decoded to blank lines
1328        let s = r#"Format: https://www.debian.org/doc/packaging-manuals/copyright-format/1.0/
1329
1330License: MIT
1331 Permission is hereby granted, free of charge, to any person obtaining a copy
1332 of this software and associated documentation files.
1333 .
1334 THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND.
1335"#;
1336        let copyright = s.parse::<super::Copyright>().expect("failed to parse");
1337        let license_para = copyright
1338            .iter_licenses()
1339            .next()
1340            .expect("no license paragraph");
1341        let text = license_para.text().expect("no license text");
1342
1343        // The period marker should be decoded to a blank line
1344        assert!(
1345            text.contains("\n\n"),
1346            "Expected blank line in decoded text, got: {:?}",
1347            text
1348        );
1349        assert!(
1350            !text.contains("\n.\n"),
1351            "Period marker should be decoded, not present in output"
1352        );
1353
1354        // Verify exact content
1355        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.";
1356        assert_eq!(text, expected);
1357    }
1358
1359    #[test]
1360    fn test_license_enum_decoding() {
1361        // Test that the license() method also decodes paragraph markers
1362        let s = r#"Format: https://www.debian.org/doc/packaging-manuals/copyright-format/1.0/
1363
1364License: GPL-3+
1365 This program is free software.
1366 .
1367 You can redistribute it.
1368"#;
1369        let copyright = s.parse::<super::Copyright>().expect("failed to parse");
1370        let license_para = copyright
1371            .iter_licenses()
1372            .next()
1373            .expect("no license paragraph");
1374        let license = license_para.license();
1375
1376        match license {
1377            crate::License::Named(name, text) => {
1378                assert_eq!(name, "GPL-3+");
1379                assert!(text.contains("\n\n"), "Expected blank line in decoded text");
1380                assert!(!text.contains("\n.\n"), "Period marker should be decoded");
1381                assert_eq!(
1382                    text,
1383                    "This program is free software.\n\nYou can redistribute it."
1384                );
1385            }
1386            _ => panic!("Expected Named license"),
1387        }
1388    }
1389
1390    #[test]
1391    fn test_encode_field_text() {
1392        // Test basic encoding of blank lines
1393        let input = "line 1\n\nline 3";
1394        let output = super::encode_field_text(input);
1395        assert_eq!(output, "line 1\n.\nline 3");
1396    }
1397
1398    #[test]
1399    fn test_encode_decode_round_trip() {
1400        // Test that encoding and decoding are inverse operations
1401        let original = "First paragraph\n\nSecond paragraph\n\nThird paragraph";
1402        let encoded = super::encode_field_text(original);
1403        let decoded = super::decode_field_text(&encoded);
1404        assert_eq!(
1405            decoded, original,
1406            "Round-trip encoding/decoding should preserve text"
1407        );
1408    }
1409
1410    #[test]
1411    fn test_set_license_with_blank_lines() {
1412        // Test that setting a license with blank lines encodes them properly
1413        let s = r#"Format: https://www.debian.org/doc/packaging-manuals/copyright-format/1.0/
1414
1415License: GPL-3+
1416 Original text
1417"#;
1418        let copyright = s.parse::<super::Copyright>().expect("failed to parse");
1419        let mut license_para = copyright
1420            .iter_licenses()
1421            .next()
1422            .expect("no license paragraph");
1423
1424        // Set license text with blank lines
1425        let new_license = crate::License::Named(
1426            "GPL-3+".to_string(),
1427            "First paragraph.\n\nSecond paragraph.".to_string(),
1428        );
1429        license_para.set_license(&new_license);
1430
1431        // Verify it was encoded properly in the raw deb822
1432        let raw_text = copyright.to_string();
1433        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";
1434        assert_eq!(raw_text, expected_output);
1435
1436        // Verify it decodes back correctly
1437        let retrieved = license_para.text().expect("no text");
1438        assert_eq!(retrieved, "First paragraph.\n\nSecond paragraph.");
1439    }
1440
1441    #[test]
1442    fn test_set_text_with_blank_lines() {
1443        // Test that set_text also encodes blank lines
1444        let s = r#"Format: https://www.debian.org/doc/packaging-manuals/copyright-format/1.0/
1445
1446License: MIT
1447 Original text
1448"#;
1449        let copyright = s.parse::<super::Copyright>().expect("failed to parse");
1450        let mut license_para = copyright
1451            .iter_licenses()
1452            .next()
1453            .expect("no license paragraph");
1454
1455        // Set text with blank lines
1456        license_para.set_text(Some("Line 1\n\nLine 2"));
1457
1458        // Verify encoding
1459        let raw_text = copyright.to_string();
1460        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";
1461        assert_eq!(raw_text, expected_output);
1462
1463        // Verify decoding
1464        let retrieved = license_para.text().expect("no text");
1465        assert_eq!(retrieved, "Line 1\n\nLine 2");
1466    }
1467
1468    #[test]
1469    fn test_set_license_uses_single_space_indent_for_new_multiline() {
1470        // Test that set_license() uses 1-space indentation when converting
1471        // a single-line license (no existing indentation) to multi-line
1472        let s = r#"Format: https://www.debian.org/doc/packaging-manuals/copyright-format/1.0/
1473
1474License: Apache-2.0
1475"#;
1476        let copyright = s.parse::<super::Copyright>().expect("failed to parse");
1477        let mut license_para = copyright
1478            .iter_licenses()
1479            .next()
1480            .expect("no license paragraph");
1481
1482        // Set new multi-line license text
1483        let new_license = crate::License::Named(
1484            "Apache-2.0".to_string(),
1485            "Licensed under the Apache License, Version 2.0".to_string(),
1486        );
1487        license_para.set_license(&new_license);
1488
1489        // Verify the new license uses 1-space indentation
1490        let result = copyright.to_string();
1491        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";
1492        assert_eq!(result, expected);
1493    }
1494
1495    #[test]
1496    fn test_header_as_deb822() {
1497        let s = r#"Format: https://www.debian.org/doc/packaging-manuals/copyright-format/1.0/
1498Upstream-Name: foo
1499"#;
1500        let copyright = s.parse::<super::Copyright>().unwrap();
1501        let header = copyright.header().unwrap();
1502        let para = header.as_deb822();
1503        assert_eq!(para.get("Upstream-Name"), Some("foo".to_string()));
1504    }
1505
1506    #[test]
1507    fn test_files_paragraph_as_deb822() {
1508        let s = r#"Format: https://www.debian.org/doc/packaging-manuals/copyright-format/1.0/
1509
1510Files: *
1511Copyright: 2024 Test
1512License: MIT
1513"#;
1514        let copyright = s.parse::<super::Copyright>().unwrap();
1515        let files = copyright.iter_files().next().unwrap();
1516        let para = files.as_deb822();
1517        assert_eq!(para.get("Files"), Some("*".to_string()));
1518    }
1519
1520    #[test]
1521    fn test_license_paragraph_as_deb822() {
1522        let s = r#"Format: https://www.debian.org/doc/packaging-manuals/copyright-format/1.0/
1523
1524License: GPL-3+
1525 License text
1526"#;
1527        let copyright = s.parse::<super::Copyright>().unwrap();
1528        let license = copyright.iter_licenses().next().unwrap();
1529        let para = license.as_deb822();
1530        assert!(para.get("License").unwrap().starts_with("GPL-3+"));
1531    }
1532
1533    #[test]
1534    fn test_set_license_normalizes_unusual_indentation() {
1535        // Regression test: set_license() should NOT preserve unusual indentation
1536        // from the original paragraph, it should always use 1-space indentation
1537        let s = r#"Format: https://www.debian.org/doc/packaging-manuals/copyright-format/1.0/
1538
1539License: Apache-2.0
1540                                 Apache License
1541                           Version 2.0, January 2004
1542                        http://www.apache.org/licenses/
1543 .
1544   TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1545"#;
1546        let copyright = s.parse::<super::Copyright>().expect("failed to parse");
1547        let mut license_para = copyright
1548            .iter_licenses()
1549            .next()
1550            .expect("no license paragraph");
1551
1552        // Set new license text with normal formatting (no unusual indentation)
1553        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";
1554        let new_license = crate::License::Named("Apache-2.0".to_string(), new_text.to_string());
1555        license_para.set_license(&new_license);
1556
1557        // Verify the output uses 1-space indentation, NOT the 33-space from the original
1558        let result = copyright.to_string();
1559
1560        // The bug is now fixed - output uses 1-space indentation regardless of the original formatting
1561        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";
1562
1563        assert_eq!(result, expected);
1564    }
1565}