ReportConfiguration.java

/*
 * Licensed to the Apache Software Foundation (ASF) under one   *
 * or more contributor license agreements.  See the NOTICE file *
 * distributed with this work for additional information        *
 * regarding copyright ownership.  The ASF licenses this file   *
 * to you under the Apache License, Version 2.0 (the            *
 * "License"); you may not use this file except in compliance   *
 * with the License.  You may obtain a copy of the License at   *
 *                                                              *
 *   http://www.apache.org/licenses/LICENSE-2.0                 *
 *                                                              *
 * Unless required by applicable law or agreed to in writing,   *
 * software distributed under the License is distributed on an  *
 * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY       *
 * KIND, either express or implied.  See the License for the    *
 * specific language governing permissions and limitations      *
 * under the License.                                           *
 */
package org.apache.rat;

import java.io.File;
import java.io.FileFilter;
import java.io.FileInputStream;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.io.OutputStreamWriter;
import java.io.PrintWriter;
import java.net.MalformedURLException;
import java.net.URI;
import java.net.URL;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.util.ArrayList;
import java.util.Collection;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.SortedSet;
import java.util.function.Consumer;
import java.util.stream.Stream;

import org.apache.commons.collections4.set.UnmodifiableSortedSet;
import org.apache.commons.io.function.IOSupplier;
import org.apache.commons.io.output.CloseShieldOutputStream;
import org.apache.commons.lang3.StringUtils;
import org.apache.rat.analysis.IHeaderMatcher;
import org.apache.rat.api.RatException;
import org.apache.rat.commandline.StyleSheets;
import org.apache.rat.config.AddLicenseHeaders;
import org.apache.rat.config.exclusion.ExclusionProcessor;
import org.apache.rat.config.exclusion.StandardCollection;
import org.apache.rat.config.results.ClaimValidator;
import org.apache.rat.configuration.XMLConfigurationReader;
import org.apache.rat.configuration.builders.AnyBuilder;
import org.apache.rat.document.DocumentName;
import org.apache.rat.document.DocumentNameMatcher;
import org.apache.rat.document.FileDocument;
import org.apache.rat.license.ILicense;
import org.apache.rat.license.ILicenseFamily;
import org.apache.rat.license.LicenseSetFactory;
import org.apache.rat.license.LicenseSetFactory.LicenseFilter;
import org.apache.rat.report.RatReport;
import org.apache.rat.report.Reportable;
import org.apache.rat.report.claim.ClaimStatistic;
import org.apache.rat.report.xml.writer.XmlWriter;
import org.apache.rat.utils.DefaultLog;
import org.apache.rat.utils.Log.Level;
import org.apache.rat.utils.ReportingSet;
import org.apache.rat.utils.StandardXmlFactory;
import org.apache.rat.walker.FileListWalker;
import org.apache.rat.walker.ReportableListWalker;
import org.w3c.dom.Node;
import org.xml.sax.SAXException;

import edu.umd.cs.findbugs.annotations.SuppressFBWarnings;

/**
 * A configuration object is used by the front end to invoke the
 * {@link Reporter}. The sole purpose of the frontends is to create the
 * configuration and invoke the {@link Reporter}.
 */
public class ReportConfiguration {

    /** The IODescriptor for {@code System.out}. */
    public static final IODescriptor<OutputStream> SYSTEM_OUT =
            // SONAR wants to require logging output, which is the wrong reporting channel for this case.
            new IODescriptor<>("System.out", () -> CloseShieldOutputStream.wrap(System.out)); // NOSONAR

    /**
     * The styles of processing for various categories of documents.
     */
    public enum Processing {
        /** List file as present only. */
        NOTIFICATION("List file as present"),
        /** List all present licenses. */
        PRESENCE("List any licenses found"),
        /** List all present licenses and unknown licenses. */
        ABSENCE("List licenses found and any unknown licences");

        /**
         * Description of the processing.
         */
        private final String description;

        Processing(final String description) {
            this.description = description;
        }

        /**
         * Gets the description of the processing type.
         * @return the description of the processing type.
         */
        public String desc() {
            return description;
        }
    }

    /** The LicenseSetFactory for the configuration */
    private final LicenseSetFactory licenseSetFactory;

    /**
     * {@code true} if we are adding license headers to the files.
     */
    private boolean addingLicenses;

    /**
     * {@code true} if we are adding license headers in place (no *.new files)
     */
    private boolean addingLicensesForced;

    /**
     * The copyright message to add if we are adding headers. Will be {@code null}
     * if we are not adding copyright messages.
     */
    private String copyrightMessage;

    /**
     * The IODescriptor that provides the output stream to write the report to.
     */
    private IODescriptor<OutputStream> out;

    /**
     * The IODescriptor that provides the stylesheet to style the XML output.
     */
    private IODescriptor<InputStream> styleSheet;

    /**
     * A list of files to read file names from.
     */
    private final List<File> sources;

    /**
     * A list of reportables to process.
     */
    private final List<Reportable> reportables;

    /**
     * The exclusion processor that determines if a file is included or excluded.
     */
    private final ExclusionProcessor exclusionProcessor;

    /**
     * The default filter for displaying families.
     */
    private LicenseFilter listFamilies;

    /**
     * The default filter for displaying licenses.
     */
    private LicenseFilter listLicenses;

    /**
     * {@code true} if this is a dry run and no processing is to take place.
     */
    private boolean dryRun;

    /**
     * How to process ARCHIVE document types.
     */
    private Processing archiveProcessing;

    /**
     * How to process STANDARD document types.
     */
    private Processing standardProcessing;

    /**
     * The ClaimValidator to validate min/max counts and similar claims.
     */
    private final ClaimValidator claimValidator;

    /**
     * Constructor
     */
    public ReportConfiguration() {
        licenseSetFactory = new LicenseSetFactory();
        listFamilies = Defaults.LIST_FAMILIES;
        listLicenses = Defaults.LIST_LICENSES;
        dryRun = false;
        exclusionProcessor = new ExclusionProcessor();
        claimValidator = new ClaimValidator();
        sources = new ArrayList<>();
        reportables = new ArrayList<>();
    }

    public SerDes serDes() {
        return new SerDes();
    }

    /**
     * Report the excluded files to the appendable object.
     * @param appendable the appendable object to write to.
     */
    public void reportExclusions(final Appendable appendable) {
        try {
            exclusionProcessor.reportExclusions(appendable);
        } catch (IOException e) {
            DefaultLog.getInstance().warn("Unable to report exclusions", e);
        }
    }

    /**
     * Adds a file as a source of files to scan.
     * The file must be a text file that lists files to be included.
     * File within the file must be in linux format with a
     * {@code "/"} file separator.
     * @param file the file to process.
     */
    public void addSource(final File file) {
        notNull(file, "File may not be null.");
        sources.add(file);
    }

    private void notNull(final Object o, final String msg) {
        if (o == null) {
            throw new ConfigurationException(msg);
        }
    }

    /**
     * Adds a Reportable as a source of files to scan.
     * @param reportable the reportable to process.
     */
    public void addSource(final Reportable reportable) {
        notNull(reportable, "Reportable may not be null.");
        reportables.add(reportable);
    }

    /**
     * Returns {@code true} if the configuration has any sources defined.
     * @return {@code true} if the configuration has any sources defined.
     */
    public boolean hasSource() {
        return !reportables.isEmpty() || !sources.isEmpty();
    }

    /**
     * Gets a builder initialized with any files specified as sources.
     * @return a configured builder.
     */
    public ReportableListWalker.Builder getSources() {
        DocumentName name = DocumentName.builder(new File(".")).build();
        ReportableListWalker.Builder builder = ReportableListWalker.builder(name);
        sources.forEach(file -> builder.addReportable(new FileListWalker(new FileDocument(file, DocumentNameMatcher.MATCHES_ALL))));
        reportables.forEach(builder::addReportable);
        return builder;
    }

    // for testing access
    Iterable<File> sources() {
        return sources;
    }

    // for testing access
    Stream<DocumentName> reportables() {
        return reportables.stream().map(Reportable::name);
    }

    /**
     * Gets the matcher that matches generated text.
     * @return the matcher that matches generated text.
     */
    public IHeaderMatcher getGeneratedMatcher() {
        return new AnyBuilder().setResource("/org/apache/rat/generation-keywords.txt").build();
    }

    /**
     * Retrieves the archive processing type.
     * @return the archive processing type.
     */
    public Processing getArchiveProcessing() {
        return archiveProcessing == null ? Defaults.ARCHIVE_PROCESSING : archiveProcessing;
    }

    /**
     * Sets the archive processing type. If not set will default to NOTIFICATION.
     * @param archiveProcessing the type of processing archives should have.
     */
    public void setArchiveProcessing(final Processing archiveProcessing) {
        this.archiveProcessing = archiveProcessing;
    }

    /**
     * Retrieves the standard processing type.
     * @return the standard processing type.
     */
    public Processing getStandardProcessing() {
        return standardProcessing == null ? Defaults.STANDARD_PROCESSING : standardProcessing;
    }

    /**
     * Sets the standard processing type. If not set will default to NOTIFICATION.
     * @param standardProcessing the type of processing standard files should have.
     */
    public void setStandardProcessing(final Processing standardProcessing) {
        this.standardProcessing = standardProcessing;
    }

    /**
     * Set the log level for reporting collisions in the set of license families.
     * <p>NOTE: should be set before licenses or license families are added.</p>
     * @param level the log level to use.
     */
    public void logFamilyCollisions(final Level level) {
        licenseSetFactory.logFamilyCollisions(level);
    }

    /**
     * Sets the reporting option for duplicate license families.
     * @param state the ReportingSet.Option to use for reporting.
     */
    public void familyDuplicateOption(final ReportingSet.Options state) {
        licenseSetFactory.familyDuplicateOption(state);
    }

    /**
     * Sets the log level for reporting license collisions.
     * @param level the log level.
     */
    public void logLicenseCollisions(final Level level) {
        licenseSetFactory.logLicenseCollisions(level);
    }

    /**
     * Sets the reporting option for duplicate licenses.
     * @param state the ReportingSet.Option to use for reporting.
     */
    public void licenseDuplicateOption(final ReportingSet.Options state) {
        licenseSetFactory.licenseDuplicateOption(state);
    }

    /**
     * Set the level of license families that should be output in the XML document.
     * @param filter the license families to list.
     */
    public void listFamilies(final LicenseFilter filter) {
        listFamilies = filter;
    }

    /**
     * Return the current filter that determines which families will be output in the XML document.
     * @return the filter that defines the families to list.
     */
    public LicenseFilter listFamilies() {
        return listFamilies;
    }

    /**
     * Set the level of licenses that should be output in the XML document.
     * @param filter the licenses to list.
     */
    public void listLicenses(final LicenseFilter filter) {
        listLicenses = filter;
    }

    /**
     * Gets the selected license filter.
     * @return the filter to limit license display.
     */
    public LicenseFilter listLicenses() {
        return listLicenses;
    }

    /**
     * Sets the dry run flag.
     * @param state the state for the dry run flag.
     */
    public void setDryRun(final boolean state) {
        dryRun = state;
    }

    /**
     * Returns the state of the dry run flag.
     * @return the state of the dry run flag.
     */
    public boolean isDryRun() {
        return dryRun;
    }

    /**
     * Excludes a StandardCollection of patterns.
     * @param collection the StandardCollection to exclude.
     * @see ExclusionProcessor#addExcludedCollection(StandardCollection)
     */
    public void addExcludedCollection(final StandardCollection collection) {
        exclusionProcessor.addExcludedCollection(collection);
    }

    /**
     * Excludes the file processor defined in the StandardCollection.
     * @param collection the StandardCollection to exclude.
     * @see ExclusionProcessor#addFileProcessor(StandardCollection)
     */
    public void addExcludedFileProcessor(final StandardCollection collection) {
        exclusionProcessor.addFileProcessor(collection);
    }

    /**
     * Excludes files that match a FileFilter.
     * @param fileFilter the file filter to match.
     */
    public void addExcludedFilter(final FileFilter fileFilter) {
        exclusionProcessor.addExcludedMatcher(new DocumentNameMatcher(fileFilter));
    }

    /**
     * Excludes files that match a DocumentNameMatcher.
     * @param matcher the DocumentNameMatcher to match.
     */
    public void addExcludedMatcher(final DocumentNameMatcher matcher) {
        exclusionProcessor.addExcludedMatcher(matcher);
    }

    /**
     * Excludes files that match the pattern.
     *
     * @param patterns the collection of patterns to exclude.
     * @see ExclusionProcessor#addIncludedPatterns(Iterable)
     */
    public void addExcludedPatterns(final Iterable<String> patterns) {
        exclusionProcessor.addExcludedPatterns(patterns);
    }

    /**
     * Adds the patterns from the standard collection as included patterns.
     * @param collection the standard collection to include.
     */
    public void addIncludedCollection(final StandardCollection collection) {
        exclusionProcessor.addIncludedCollection(collection);
    }

    /**
     * Adds the fileFilter to filter files that should be included, this overrides any
     * exclusion of the same files.
     * @param fileFilter the filter to identify files that should be included.
     */
    public void addIncludedFilter(final FileFilter fileFilter) {
        exclusionProcessor.addIncludedMatcher(new DocumentNameMatcher(fileFilter));
    }

    /**
     * Includes files that match a DocumentNameMatcher.
     * @param matcher the DocumentNameMatcher to match.
     */
    public void addIncludedMatcher(final DocumentNameMatcher matcher) {
        exclusionProcessor.addIncludedMatcher(matcher);
    }

    /**
     * Add file patterns that are to be included. These patterns override any exclusion of
     * the same files.
     * @param patterns the iterable of Strings containing the patterns.
     */
    public void addIncludedPatterns(final Iterable<String> patterns) {
        exclusionProcessor.addIncludedPatterns(patterns);
    }

    /**
     * Get the DocumentNameMatcher that excludes files found in the directory tree.
     * @param baseDir the DocumentName for the base directory.
     * @return the DocumentNameMatcher for the base directory.
     */
    public DocumentNameMatcher getDocumentExcluder(final DocumentName baseDir) {
        return exclusionProcessor.getNameMatcher(baseDir);
    }

    // visible for testing.
    ExclusionProcessor getExclusionProcessor() {
        return exclusionProcessor;
    }

    /**
     * Gets the IOSupplier with the style sheet.
     * @return the Supplier of the InputStream that is the XSLT style sheet to style
     * the report with.
     */
    public IOSupplier<InputStream> getStyleSheet() {
        return styleSheet == null ? null : styleSheet.ioSupplier();
    }

    /**
     * Gets the IODescriptor with the style sheet.
     * @return the IODescriptor that describes the XSLT style sheet to style
     * the report with.
     */
    public IODescriptor<InputStream> getStyleSheetDescriptor() {
        return styleSheet == null ? null : styleSheet;
    }

    /**
     * Sets the style sheet for custom processing. The IODescriptor may be called
     * multiple times, so the input stream must be able to be opened and closed
     * multiple times.
     * @param styleSheet the XSLT style sheet to style the report with.
     */
    public void setStyleSheet(final IODescriptor<InputStream> styleSheet) {
        this.styleSheet = styleSheet;
    }

    /**
     * Adds the licenses and approved licenses from the defaults object to the
     * configuration. <em>Side effect:</em> if the report should be styled and no
     * style sheet has been set the plain stylesheet from the defaults will be used.
     * @param defaults the defaults to set.
     */
    public void setFrom(final Defaults defaults) {
        licenseSetFactory.add(defaults.getLicenseSetFactory());
        if (getStyleSheet() == null) {
            setStyleSheet(StyleSheets.PLAIN.getStyleSheet());
        }
        defaults.getStandardExclusion().forEach(this::addExcludedCollection);
    }

    /**
     * Sets the style sheet.
     * @param styleSheet the XSLT style sheet file to style the report with.
     */
    public void setStyleSheet(final File styleSheet) {
        Objects.requireNonNull(styleSheet, "styleSheet file should not be null");
        setStyleSheet(styleSheet.toURI());
    }

    /**
     * Sets the style sheet for custom processing. The stylesheet may be opened
     * multiple times so the URI must be capable of being opened multiple times.
     * @param styleSheet the URI of the XSLT style sheet to style the report with.
     */
    public void setStyleSheet(final URI styleSheet) {
        Objects.requireNonNull(styleSheet, "Stylesheet file must not be null");
        try {
            setStyleSheet(styleSheet.toURL());
        } catch (MalformedURLException e) {
            throw new ConfigurationException("Unable to process stylesheet", e);
        }
    }

    /**
     * Sets the style sheet for custom processing. The stylesheet may be opened
     * multiple times so the URL must be capable of being opened multiple times.
     * @param styleSheet the URL of the XSLT style sheet to style the report with.
     */
    public void setStyleSheet(final URL styleSheet) {
        Objects.requireNonNull(styleSheet, "Stylesheet file must not be null");
        setStyleSheet(new IODescriptor<>(styleSheet.toString(), styleSheet::openStream));
    }

    /**
     * Sets the supplier for the output stream. The supplier may be called multiple
     * times to provide the stream. Suppliers should prepare streams that are
     * appended to and that can be closed. If an {@code OutputStream} should not be
     * closed consider wrapping it in a {@code CloseShieldOutputStream}
     * @param out the OutputStream supplier that provides the output stream to write
     * the report to. A {@code null} value will use {@code System.out}.
     * @see CloseShieldOutputStream
     */
    public void setOut(final IODescriptor<OutputStream> out) {
        this.out = out;
    }

    /**
     * Sets the OutputStream supplier to use the specified file. The file may be
     * opened and closed several times. File is deleted first and then may be
     * repeatedly opened in append mode.
     * @see #setOut(IODescriptor)
     * @param file The file to create the supplier with.
     */
    public void setOut(final File file) {
        Objects.requireNonNull(file, "output file should not be null");
        if (file.exists()) {
            try {
                Files.delete(file.toPath());
            } catch (IOException e) {
                DefaultLog.getInstance().warn("Unable to delete file: " + file);
            }
        }

        File parent = file.getParentFile();
        if (!parent.mkdirs() && !parent.isDirectory()) {
            DefaultLog.getInstance().warn("Unable to create directory: " + file.getParentFile());
        }
        setOut(IODescriptor.output(file));
    }

    /**
     * Returns the output stream supplier. If no stream has been set returns a
     * supplier for {@code System.out}.
     * @return the supplier of the output stream to write the report to.
     */
    public IOSupplier<OutputStream> getOutput() {
        return getOutputDescriptor().ioSupplier();
    }

    /**
     * Returns the output IODescriptor. If no stream has been set returns a
     * descriptor for {@code System.out}.
     * @return the IODescriptor of the output stream to write the report to.
     */
    public IODescriptor<OutputStream> getOutputDescriptor() {
        return out == null ? SYSTEM_OUT : out;
    }

    /**
     * Gets a PrintWriter that wraps the output stream.
     * @return a supplier for a PrintWriter that wraps the output stream.
     * @see #getOutput()
     */
    public IOSupplier<PrintWriter> getWriter() {
        return () -> new PrintWriter(new OutputStreamWriter(getOutput().get(), StandardCharsets.UTF_8));
    }

    /**
     * Adds a license to the list of licenses. Does not add the license to the list
     * of approved licenses.
     * @param license the license to add to the list of licenses.
     */
    public void addLicense(final ILicense license) {
        licenseSetFactory.addLicense(license);
    }

    /**
     * Adds a license to the list of licenses. Does not add the license to the list
     * of approved licenses.
     * @param builder the license builder to build and add to the list of licenses.
     * @return the ILicense implementation that was added.
     */
    public ILicense addLicense(final ILicense.Builder builder) {
        return licenseSetFactory.addLicense(builder);
    }

    /**
     * Adds multiple licenses to the list of licenses. Does not add the licenses to
     * the list of approved licenses.
     * @param licenses the licenses to add.
     */
    public void addLicenses(final Collection<ILicense> licenses) {
        licenseSetFactory.addLicenses(licenses);
    }

    /**
     * Adds a license family to the list of families. Does not add the family to the
     * list of approved licenses.
     * @param family the license family to add to the list of license families.
     */
    public void addFamily(final ILicenseFamily family) {
       licenseSetFactory.addFamily(family);
    }

    /**
     * Adds a license family to the list of families. Does not add the family to the
     * list of approved licenses.
     * @param builder the licenseFamily.Builder to build and add to the list of
     * licenses.
     */
    public void addFamily(final ILicenseFamily.Builder builder) {
        licenseSetFactory.addFamily(builder);
    }

    /**
     * Adds multiple families to the list of license families. Does not add the
     * licenses to the list of approved licenses.
     * @param families the license families to add.
     */
    public void addFamilies(final Collection<ILicenseFamily> families) {
        families.forEach(this::addApprovedLicenseCategory);
    }

    /**
     * Adds an ILicenseFamily to the list of approved licenses.
     * @param approvedILicenseFamily the LicenseFamily to add.
     */
    public void addApprovedLicenseCategory(final ILicenseFamily approvedILicenseFamily) {
        addApprovedLicenseCategory(approvedILicenseFamily.getFamilyCategory());
    }

    /**
     * Adds a license family category (id) to the list of approved licenses.
     * @param familyCategory the category to add.
     */
    public void addApprovedLicenseCategory(final String familyCategory) {
        licenseSetFactory.approveLicenseCategory(familyCategory);
    }

    /**
     * Adds a collection of license family categories to the set of approved license
     * names.
     * @param approvedLicenseCategories set of approved license categories.
     */
    public void addApprovedLicenseCategories(final Collection<String> approvedLicenseCategories) {
        approvedLicenseCategories.forEach(this::addApprovedLicenseCategory);
    }

    /**
     * Adds a license family category to the list of approved licenses. <em>Once a
     * license has been removed from the approved list it cannot be re-added</em>
     * @param familyCategory the category to add.
     */
    public void removeApprovedLicenseCategory(final String familyCategory) {
        licenseSetFactory.removeLicenseCategory(ILicenseFamily.makeCategory(familyCategory));
    }

    /**
     * Removes a license family category from the list of approved licenses.
     * <em>Once a license has been removed from the approved list it cannot be
     * re-added</em>
     * @param familyCategory the family category to remove.
     */
    public void removeApprovedLicenseCategories(final Collection<String> familyCategory) {
        familyCategory.forEach(this::removeApprovedLicenseCategory);
    }

    /**
     * Gets the SortedSet of approved license categories. <em>Once a license has
     * been removed from the approved list it cannot be re-added</em>
     * @param filter the LicenseFilter to filter the categories by.
     * @return the Sorted set of approved license categories.
     */
    public SortedSet<String> getLicenseCategories(final LicenseFilter filter) {
        return licenseSetFactory.getLicenseCategories(filter);
    }

    /**
     * Gets the SortedSet of approved license categories. <em>Once a license has
     * been removed from the approved list it cannot be re-added</em>
     * @param filter the LicenseFilter to filter the licenses by.
     * @return the Sorted set of approved license categories.
     */
    public UnmodifiableSortedSet<ILicense> getLicenses(final LicenseFilter filter) {
        return licenseSetFactory.getLicenses(filter);
    }

    /**
     * Gets the SortedSet of approved license categories. <em>Once a license has
     * been removed from the approved list it cannot be re-added</em>
     * @param filter the LicenseFilter to filter the licenses by.
     * @return the Sorted set of approved license categories.
     */
    public SortedSet<String> getLicenseIds(final LicenseFilter filter) {
        return licenseSetFactory.getLicenseIds(filter);
    }

    /**
     * Adds an ILicenseFamily to the list of approved licenses.
     * @param approvedLicense the License to add.
     */
    public void addApprovedLicenseId(final ILicense approvedLicense) {
        addApprovedLicenseId(approvedLicense.getId());
    }

    /**
     * Adds a license family category (id) to the list of approved licenses
     * @param licenseId the license id to add.
     */
    public void addApprovedLicenseId(final String licenseId) {
        licenseSetFactory.approveLicenseId(licenseId);
    }

    /**
     * Adds a collection of license family categories to the set of approved license
     * names.
     * @param approvedLicenseIds set of approved license IDs.
     */
    public void addApprovedLicenseIds(final Collection<String> approvedLicenseIds) {
        approvedLicenseIds.forEach(this::addApprovedLicenseId);
    }

    /**
     * Adds a license family category to the list of approved licenses. <em>Once a
     * license has been removed from the approved list it cannot be re-added</em>
     * @param licenseId the license ID to add.
     */
    public void removeApprovedLicenseId(final String licenseId) {
        licenseSetFactory.removeLicenseId(licenseId);
    }

    /**
     * Removes a license family category from the list of approved licenses.
     * <em>Once a license has been removed from the approved list it cannot be
     * re-added</em>
     * @param licenseIds the license IDs to remove.
     */
    public void removeApprovedLicenseIds(final Collection<String> licenseIds) {
        licenseIds.forEach(this::removeApprovedLicenseId);
    }

    /**
     * Returns the optional license copyright being added if RAT is adding headers.
     * This value is ignored, if no license headers are added.
     * @return the optional copyright message.
     * @see #isAddingLicenses()
     */
    public String getCopyrightMessage() {
        return copyrightMessage;
    }

    /**
     * Sets the optional copyright message used if RAT is adding license headers.
     * This value is ignored, if no license headers are added.
     * @param copyrightMessage message to set.
     * @see #isAddingLicenses()
     */
    public void setCopyrightMessage(final String copyrightMessage) {
        this.copyrightMessage = copyrightMessage;
    }

    /**
     * Gets the flag that determines if license headers are "forced" overwriting existing files.
     * This value is ignored if RAT is not adding licenses.
     * @return {@code true} if RAT is forcing the adding license headers.
     * @see #isAddingLicenses()
     */
    public boolean isAddingLicensesForced() {
        return addingLicensesForced;
    }

    /**
     * Gets the flag that determines if license headers should be added if missing.
     * @return whether RAT should add missing license headers.
     * @see #isAddingLicensesForced()
     * @see #getCopyrightMessage()
     */
    public boolean isAddingLicenses() {
        return addingLicenses;
    }

    /**
     * Sets whether RAT should enable, disable, or force the adding of license
     * headers.
     * @param addLicenseHeaders enables/disables or forces adding of licenses
     * headers.
     * @see #isAddingLicenses()
     * @see #setCopyrightMessage(String)
     */
    public void setAddLicenseHeaders(final AddLicenseHeaders addLicenseHeaders) {
        addingLicenses = false;
        addingLicensesForced = false;
        switch (addLicenseHeaders) {
        case FALSE:
            // do nothing
            break;
        case FORCED:
            addingLicensesForced = true;
            addingLicenses = true;
            break;
        case TRUE:
            addingLicenses = true;
            break;
        }
    }

    /**
     * Gets a sorted set of ILicenseFamily objects based on {@code filter}. If
     * filter is set to:
     * <ul>
     * <li>{@code all} - All licenses families will be returned.</li>
     * <li>{@code approved} - Only approved license families will be returned.</li>
     * <li>{@code none} - No license families will be returned.</li>
     * </ul>
     * @param filter the license filter.
     * @return the set of defined licenses.
     */
    public SortedSet<ILicenseFamily> getLicenseFamilies(final LicenseFilter filter) {
        return licenseSetFactory.getLicenseFamilies(filter);
    }

    /**
     * Gets the ClaimValidator for the configuration.
     * @return the ClaimValidator.
     */
    public ClaimValidator getClaimValidator() {
        return claimValidator;
    }

    /**
     * Gets the enclosed LicenseSetFactory.
     * @return the license set factory.
     */
    public LicenseSetFactory getLicenseSetFactory() {
        return licenseSetFactory;
    }

    /**
     * Validates that the configuration is valid.
     * @param logger String consumer to log warning messages to.
     * @throws ConfigurationException on configuration error.
     */
    public void validate(final Consumer<String> logger) {
        if (!hasSource()) {
            String msg = "At least one source must be specified";
            logger.accept(msg);
            throw new ConfigurationException(msg);
        }
        if (licenseSetFactory.getLicenses(LicenseFilter.ALL).isEmpty()) {
            String msg = "You must specify at least one license";
            logger.accept(msg);
            throw new ConfigurationException(msg);
        }
    }

    /**
     * An IODescriptor comprises a name and an IOSupplier. The name should identify the contents of the stream.
     * @param name the name of the supplier.
     * @param ioSupplier the IOSupplier that provides either an InputStream or an OutputStream.
     * @param <T> either InputStream or OutputStream.
     */
    public record IODescriptor<T>(String name, IOSupplier<T> ioSupplier) {

        // OUTPUT CONSTRUCTORS
        /**
         * Creates an output IODescriptor for the file name within the working directory.
         * @param name the name of the file to open.
         * @param workingDirectory the working directory for the file.
         * @return the Output IODescriptor.
         */
        static IODescriptor<OutputStream> output(final String name, final DocumentName workingDirectory) {
            DocumentName docName = workingDirectory.resolve(name);
            return new IODescriptor<>(name, () -> new FileOutputStream(docName.asFile()));
        }

        /**
         * Creates an output IODescriptor for the file. Does not modify for working directory.
         * @param file the file to open.
         * @return the Output IODescriptor.
         */
        static IODescriptor<OutputStream> output(final File file) {
            return new IODescriptor<>(file.toString(), () -> new FileOutputStream(file, true));
        }

        // INPUT CONSTRUCTORS
        /**
         * Creates an input IODescriptor for the file. Does not modify for working directory.
         * @param file the file to open.
         * @return the Input IODescriptor.
         */
        static IODescriptor<InputStream> input(final File file) {
            return new IODescriptor<>(file.toString(), () -> new FileInputStream(file));
        }
    }

    /**
     * Serializes the ReportConfiguration into an XML document that can be deserialized by the Serde.
     * Deserialized ReportConfigurations can not be executed as the reportable objects use named placeholders
     * and do not have access to the original object.
     */
    @SuppressFBWarnings("EI_EXPOSE_REP2")
    public class SerDes {
        /**
         * Writes the configuration as an XML document to the appendable.
         *
         * @param appendable the Appendable to write to.
         * @throws IOException on error.
         */
        public void serialize(final Appendable appendable) throws IOException {
            try (XmlWriter writer = new XmlWriter(appendable)) {
                writer.startElement("ReportConfiguration")
                        .attribute("addingLicenses", Boolean.toString(addingLicenses))
                        .attribute("addingLicensesForced", Boolean.toString(addingLicensesForced))
                        .attribute("listFamilies", listFamilies.name())
                        .attribute("listLicenses", listLicenses.name())
                        .attribute("dryRun", Boolean.toString(dryRun))
                        .attribute("archiveProcessing", getArchiveProcessing().name())
                        .attribute("standardProcessing", getStandardProcessing().name())
                        .attribute("stylesheet", styleSheet.name())
                        .attribute("output", out.name());
                if (StringUtils.isNotEmpty(copyrightMessage)) {
                    writer.startElement("copyrightMessage").content(copyrightMessage).closeElement();
                }
                writer.startElement("sources");
                for (File f : sources) {
                    writer.startElement("source").attribute("name", f.toString()).closeElement();
                }
                writer.closeElement("sources").startElement("reportables");
                for (Reportable reportable : reportables) {
                    writer.startElement("reportable")
                            .attribute("baseName", reportable.name().getBaseName())
                            .attribute("name", reportable.name().toString())
                            .attribute("class", reportable.getClass().getName()).closeElement();
                }
                writer.closeElement();

                exclusionProcessor.serDes().serialize(writer);

                writer.startElement("claimValidator");
                for (ClaimStatistic.Counter counter : ClaimStatistic.Counter.values()) {
                    writer.startElement("claimCounter")
                            .attribute("name", counter.name()).attribute("min", Integer.toString(claimValidator.getMin(counter)))
                            .attribute("max", Integer.toString(claimValidator.getMax(counter))).closeElement();
                }
                writer.closeElement();
            } catch (IOException e) {
                throw e;
            } catch (Exception e) {
                throw new IOException(e);
            }
        }

        public void deserialize(final IOSupplier<InputStream> inputStreamSupplier, final DocumentName workingDirectory) throws IOException {
            org.w3c.dom.Document document;
            try (InputStream stream = inputStreamSupplier.get()) {
                document = StandardXmlFactory.documentBuilder().parse(stream);
            } catch (SAXException e) {
                throw new IOException("Unable to read input", e);
            }
            Node node = document.getDocumentElement();
            if (!node.getNodeName().equals("ReportConfiguration")) {
                throw new IOException("Invalid ReportConfiguration");
            }
            Map<String, String> attributes = XMLConfigurationReader.attributes(node);
            addingLicenses = Boolean.parseBoolean(attributes.get("addingLicenses"));
            addingLicensesForced = Boolean.parseBoolean(attributes.get("addingLicensesForced"));
            listFamilies = LicenseFilter.valueOf(attributes.get("listFamilies"));
            listLicenses = LicenseFilter.valueOf(attributes.get("listLicenses"));
            dryRun = Boolean.parseBoolean(attributes.get("dryRun"));
            archiveProcessing = Processing.valueOf(attributes.get("archiveProcessing"));
            standardProcessing = Processing.valueOf(attributes.get("standardProcessing"));
            String styleName = attributes.get("stylesheet");
            if (styleName != null) {
                styleSheet = StyleSheets.getStyleSheet(styleName);
            }
            String outputName = attributes.get("output");
            if (outputName != null) {
                if (outputName.equals(ReportConfiguration.SYSTEM_OUT.name())) {
                    out = ReportConfiguration.SYSTEM_OUT;
                } else {
                    out = IODescriptor.output(outputName, workingDirectory);
                }
            }

            XMLConfigurationReader.nodeListConsumer(document.getElementsByTagName("copyrightMessage"),
                    lNode -> setCopyrightMessage(lNode.getTextContent()));

            XMLConfigurationReader.nodeListConsumer(document.getElementsByTagName("source"), lNode -> {
                Map<String, String> nAttributes = XMLConfigurationReader.attributes(lNode);
                addSource(new File(nAttributes.get("name")));
            });

            // Deserialize the reportables.
            XMLConfigurationReader.nodeListConsumer(document.getElementsByTagName("reportable"), lNode -> {
                Map<String, String> nAttributes = XMLConfigurationReader.attributes(lNode);
                DocumentName documentName = DocumentName.builder().setBaseName(nAttributes.get("baseName"))
                        .setName(nAttributes.get("name")).build();
                addSource(new DeserializedReportable(documentName));
            });

            exclusionProcessor.serDes().deserialize(document.getElementsByTagName("ExclusionProcessor").item(0));

            XMLConfigurationReader.nodeListConsumer(document.getElementsByTagName("claimCounter"), lNode -> {
                Map<String, String> nAttributes = XMLConfigurationReader.attributes(lNode);
                ClaimStatistic.Counter counter = ClaimStatistic.Counter.valueOf(nAttributes.get("name"));
                claimValidator.setMin(counter, Integer.parseInt(nAttributes.get("min")));
                claimValidator.setMax(counter, Integer.parseInt(nAttributes.get("max")));
            });
        }
    }

    /**
     * A record that identifies a deserialized reportable. Deserialized reportables are not executable.
     * @param name the name of the reportable.
     */
    private record DeserializedReportable(DocumentName name) implements Reportable {
        @Override
        public void run(final RatReport report) throws RatException {
            throw new RatException("Attempt to run a deserialized reportable");
        }
    }
}