lintian-brush 0.182.0

Automatic lintian issue fixer
use crate::declare_detector;
use crate::diagnostic::{Action, ActionPlan, Deb822Action, Diagnostic, ParagraphSelector};
use crate::{FixerError, FixerPreferences, LintianIssue, Visibility};
use debian_workspace::Workspace;
use std::path::PathBuf;
use std::str::FromStr;

/// dpkg release in which Priority: optional became the implicit default.
const DPKG_DEFAULT_PRIORITY_OPTIONAL: &str = "1.22.13";

pub fn detect(
    ws: &dyn Workspace,
    preferences: &FixerPreferences,
) -> Result<Vec<Diagnostic>, FixerError> {
    // Priority: optional is only redundant once the targeted release ships a
    // dpkg that defaults to it; before that, dropping the field changes the
    // package's priority.
    let compat_release = preferences.compat_release.as_deref().unwrap_or("sid");
    let default_priority_is_optional = match debian_analyzer::release_info::dpkg_versions
        .get(compat_release)
    {
        Some(oldest_dpkg) => {
            *oldest_dpkg >= debversion::Version::from_str(DPKG_DEFAULT_PRIORITY_OPTIONAL).unwrap()
        }
        // Unknown release: assume current dpkg behaviour.
        None => true,
    };
    if !default_priority_is_optional {
        return Ok(Vec::new());
    }

    let control = match ws.parsed_control() {
        Ok(c) => c,
        Err(debian_workspace::Error::NotFound) => return Ok(Vec::new()),
        Err(e) => return Err(e.into()),
    };

    let source_priority = control.source().as_ref().and_then(|s| s.get("Priority"));

    let mut diagnostics: Vec<Diagnostic> = Vec::new();

    if source_priority.as_deref() == Some("optional") {
        diagnostics.push(Diagnostic::with_actions(
            LintianIssue::source("redundant-priority-optional-field", Visibility::Pedantic),
            "Source stanza sets Priority: optional, which is the default.",
            "Remove redundant Priority: optional from source stanza.",
            vec![Action::Deb822(Deb822Action::RemoveField {
                file: PathBuf::from("debian/control"),
                paragraph: ParagraphSelector::Source,
                field: "Priority".into(),
            })],
        ));
    }

    // A binary's Priority: optional is only "redundant" against a default of
    // optional. If the source declares a non-optional Priority, the binary
    // override is meaningful and lintian does not emit the tag.
    let source_is_optional_or_unset =
        source_priority.is_none() || source_priority.as_deref() == Some("optional");

    if source_is_optional_or_unset {
        for binary in control.binaries() {
            if binary.get("Priority").as_deref() != Some("optional") {
                continue;
            }
            let Some(package_name) = binary.name() else {
                continue;
            };
            diagnostics.push(Diagnostic::with_actions(
                LintianIssue::binary_with_info(
                    &package_name,
                    "redundant-priority-optional-field",
                    Visibility::Pedantic,
                    vec![],
                ),
                format!(
                    "Binary stanza {} sets Priority: optional, which is the default.",
                    package_name
                ),
                format!(
                    "Remove redundant Priority: optional from binary stanza {}.",
                    package_name
                ),
                vec![Action::Deb822(Deb822Action::RemoveField {
                    file: PathBuf::from("debian/control"),
                    paragraph: ParagraphSelector::Binary {
                        package: package_name.clone(),
                    },
                    field: "Priority".into(),
                })],
            ));
        }
    }

    Ok(diagnostics)
}

/// Aggregate the per-stanza diagnostics into a single human-friendly line.
fn describe_aggregate(_fixed: &[(Diagnostic, ActionPlan)], actions: &[Action]) -> String {
    let mut source = false;
    let mut binaries: Vec<&str> = Vec::new();
    for action in actions {
        if let Action::Deb822(Deb822Action::RemoveField { paragraph, .. }) = action {
            match paragraph {
                ParagraphSelector::Source => source = true,
                ParagraphSelector::Binary { package } => binaries.push(package.as_str()),
                _ => {}
            }
        }
    }
    binaries.sort();
    binaries.dedup();

    match (source, binaries.as_slice()) {
        (true, []) => "Remove redundant Priority: optional from source stanza.".to_string(),
        (false, [pkg]) => format!(
            "Remove redundant Priority: optional from binary stanza {}.",
            pkg
        ),
        (false, pkgs) => format!(
            "Remove redundant Priority: optional from binary stanzas {}.",
            pkgs.join(", ")
        ),
        (true, pkgs) => format!(
            "Remove redundant Priority: optional from source and binary stanzas {}.",
            pkgs.join(", ")
        ),
    }
}

declare_detector! {
    name: "redundant-priority-optional-field",
    tags: ["redundant-priority-optional-field"],
    triggers: [
        debian_workspace::Trigger::Deb822Field {
            file: "debian/control",
            paragraph_key: "Source",
            field: "Priority",
        },
        debian_workspace::Trigger::Deb822Field {
            file: "debian/control",
            paragraph_key: "Package",
            field: "Priority",
        },
    ],
    detect: |ws, prefs| detect(ws, prefs),
    describe: |fixed, actions| describe_aggregate(fixed, actions),
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::detector::Detector;
    use crate::{FixerPreferences, Version};
    use debian_workspace::fs_workspace::FsWorkspace;
    use std::fs;
    use std::path::Path;
    use tempfile::TempDir;

    fn run_apply(base: &Path) -> Result<crate::FixerResult, FixerError> {
        let version: Version = "1.0".parse().unwrap();
        let adapter = DetectorImpl;
        {
            let ws = debian_workspace::fs_workspace::FsWorkspace::new(
                base,
                Some("test".into()),
                Some(version.clone()),
            );
            adapter.apply(&ws, &FixerPreferences::default())
        }
    }

    fn detect_in(base: &Path) -> Result<Vec<Diagnostic>, FixerError> {
        let ws = FsWorkspace::new(base, Some("test".into()), Some("1.0".parse().unwrap()));
        detect(&ws, &FixerPreferences::default())
    }

    fn run_apply_for(base: &Path, compat_release: &str) -> Result<crate::FixerResult, FixerError> {
        let version: Version = "1.0".parse().unwrap();
        let preferences = FixerPreferences {
            compat_release: Some(compat_release.to_string()),
            ..Default::default()
        };
        let ws = FsWorkspace::new(base, Some("test".into()), Some(version));
        DetectorImpl.apply(&ws, &preferences)
    }

    fn write_control(base: &Path, content: &str) {
        let debian = base.join("debian");
        fs::create_dir_all(&debian).unwrap();
        fs::write(debian.join("control"), content).unwrap();
    }

    #[test]
    fn test_removes_priority_from_source() {
        let temp_dir = TempDir::new().unwrap();
        let base = temp_dir.path();
        write_control(
            base,
            "Source: foo\nPriority: optional\n\nPackage: foo\nDescription: Foo\n bar\n",
        );

        let result = run_apply(base).unwrap();
        assert_eq!(
            result.description,
            "Remove redundant Priority: optional from source stanza."
        );
        assert_eq!(
            fs::read_to_string(base.join("debian/control")).unwrap(),
            "Source: foo\n\nPackage: foo\nDescription: Foo\n bar\n",
        );
    }

    #[test]
    fn test_removes_priority_from_binary_when_source_unset() {
        let temp_dir = TempDir::new().unwrap();
        let base = temp_dir.path();
        write_control(
            base,
            "Source: foo\n\nPackage: foo\nPriority: optional\nDescription: Foo\n bar\n",
        );

        let result = run_apply(base).unwrap();
        assert_eq!(
            result.description,
            "Remove redundant Priority: optional from binary stanza foo."
        );
        assert_eq!(
            fs::read_to_string(base.join("debian/control")).unwrap(),
            "Source: foo\n\nPackage: foo\nDescription: Foo\n bar\n",
        );
    }

    #[test]
    fn test_removes_priority_from_both_source_and_binary() {
        let temp_dir = TempDir::new().unwrap();
        let base = temp_dir.path();
        write_control(
            base,
            "Source: foo\nPriority: optional\n\nPackage: foo\nPriority: optional\nDescription: Foo\n bar\n",
        );

        let result = run_apply(base).unwrap();
        assert_eq!(
            result.description,
            "Remove redundant Priority: optional from source and binary stanzas foo."
        );
        assert_eq!(
            fs::read_to_string(base.join("debian/control")).unwrap(),
            "Source: foo\n\nPackage: foo\nDescription: Foo\n bar\n",
        );
    }

    #[test]
    fn test_keeps_binary_priority_when_source_non_optional() {
        // Source declares Priority: standard, so the binary's Priority: optional
        // is a meaningful override - lintian does not emit the tag, and we
        // must not strip it.
        let temp_dir = TempDir::new().unwrap();
        let base = temp_dir.path();
        let content =
            "Source: foo\nPriority: standard\n\nPackage: foo\nPriority: optional\nDescription: Foo\n bar\n";
        write_control(base, content);

        assert!(matches!(run_apply(base), Err(FixerError::NoChanges)));
        assert_eq!(
            fs::read_to_string(base.join("debian/control")).unwrap(),
            content
        );
        assert!(detect_in(base).unwrap().is_empty());
    }

    #[test]
    fn test_keeps_priority_on_older_compat_release() {
        // Priority: optional only became the implicit default in dpkg 1.22.13
        // (trixie). Targeting bookworm, the field is still required.
        let temp_dir = TempDir::new().unwrap();
        let base = temp_dir.path();
        let content =
            "Source: foo\nPriority: optional\n\nPackage: foo\nPriority: optional\nDescription: Foo\n bar\n";
        write_control(base, content);

        assert!(matches!(
            run_apply_for(base, "bookworm"),
            Err(FixerError::NoChanges)
        ));
        assert_eq!(
            fs::read_to_string(base.join("debian/control")).unwrap(),
            content
        );
    }

    #[test]
    fn test_removes_priority_on_newer_compat_release() {
        let temp_dir = TempDir::new().unwrap();
        let base = temp_dir.path();
        write_control(
            base,
            "Source: foo\nPriority: optional\n\nPackage: foo\nDescription: Foo\n bar\n",
        );

        let result = run_apply_for(base, "trixie").unwrap();
        assert_eq!(
            result.description,
            "Remove redundant Priority: optional from source stanza."
        );
        assert_eq!(
            fs::read_to_string(base.join("debian/control")).unwrap(),
            "Source: foo\n\nPackage: foo\nDescription: Foo\n bar\n",
        );
    }

    #[test]
    fn test_aggregates_multiple_binaries() {
        let temp_dir = TempDir::new().unwrap();
        let base = temp_dir.path();
        write_control(
            base,
            "Source: foo\n\nPackage: foo\nPriority: optional\nDescription: Foo\n bar\n\nPackage: bar\nPriority: optional\nDescription: Bar\n baz\n",
        );

        let result = run_apply(base).unwrap();
        assert_eq!(
            result.description,
            "Remove redundant Priority: optional from binary stanzas bar, foo."
        );
        assert_eq!(result.fixed_lintian_issues.len(), 2);
        assert_eq!(
            fs::read_to_string(base.join("debian/control")).unwrap(),
            "Source: foo\n\nPackage: foo\nDescription: Foo\n bar\n\nPackage: bar\nDescription: Bar\n baz\n",
        );
    }

    #[test]
    fn test_no_change_when_priority_unset() {
        let temp_dir = TempDir::new().unwrap();
        let base = temp_dir.path();
        let content = "Source: foo\n\nPackage: foo\nDescription: Foo\n bar\n";
        write_control(base, content);

        assert!(matches!(run_apply(base), Err(FixerError::NoChanges)));
        assert!(detect_in(base).unwrap().is_empty());
    }

    #[test]
    fn test_no_change_when_priority_non_optional() {
        let temp_dir = TempDir::new().unwrap();
        let base = temp_dir.path();
        let content = "Source: foo\nPriority: standard\n\nPackage: foo\nDescription: Foo\n bar\n";
        write_control(base, content);

        assert!(matches!(run_apply(base), Err(FixerError::NoChanges)));
        assert!(detect_in(base).unwrap().is_empty());
    }

    #[test]
    fn test_no_control_file() {
        let temp_dir = TempDir::new().unwrap();
        assert!(matches!(
            run_apply(temp_dir.path()),
            Err(FixerError::NoChanges)
        ));
    }

    #[test]
    fn test_diagnostic_carries_correct_info() {
        let temp_dir = TempDir::new().unwrap();
        let base = temp_dir.path();
        write_control(
            base,
            "Source: foo\nPriority: optional\n\nPackage: foo\nDescription: Foo\n bar\n",
        );

        let diags = detect_in(base).unwrap();
        assert_eq!(diags.len(), 1);
        let issue = diags[0].issue.as_ref().unwrap();
        assert_eq!(
            issue.tag.as_deref(),
            Some("redundant-priority-optional-field")
        );
        assert_eq!(issue.visibility, Some(Visibility::Pedantic));
    }
}