lintian-brush 0.182.0

Automatic lintian issue fixer
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
use crate::declare_detector;
use crate::diagnostic::{Action, ActionPlan, Diagnostic, FilesystemAction};
use crate::{FixerError, FixerPreferences, LintianIssue, Visibility};
use debian_workspace::Workspace;
use std::collections::HashMap;
use std::path::PathBuf;
use std::str::FromStr;

const SEQUENCE_FIELDS: &[&str] = &["Reference", "Screenshots"];

/// Result of attempting to fix duplicate keys in-memory.
struct DedupResult {
    /// New file content if any duplicates were merged. `None` means
    /// nothing to fix.
    new_content: Option<String>,
    /// Field names that had duplicates, one entry per dropped duplicate
    /// (e.g. `["Reference", "Reference"]` if there were three copies).
    duplicates: Vec<String>,
}

/// Fix duplicate keys: sequence fields get merged into a list, others
/// keep the first value. Operates in-memory. Returns `NoChange` if the
/// file is multi-document (handled by [`drop_empty_documents`] later).
fn dedup_keys(content: &str) -> Result<DedupResult, FixerError> {
    let doc = match yaml_edit::Document::from_str(content) {
        Ok(d) => d,
        Err(_) => {
            return Ok(DedupResult {
                new_content: None,
                duplicates: Vec::new(),
            });
        }
    };
    let Some(mapping) = doc.as_mapping() else {
        return Ok(DedupResult {
            new_content: None,
            duplicates: Vec::new(),
        });
    };

    let mut key_values: HashMap<String, Vec<yaml_edit::YamlNode>> = HashMap::new();
    for (key, value) in mapping.iter() {
        if let yaml_edit::YamlNode::Scalar(key_scalar) = key {
            let key_str = key_scalar.as_string();
            key_values.entry(key_str).or_default().push(value);
        }
    }

    let duplicate_keys: Vec<String> = key_values
        .iter()
        .filter(|(_, values)| values.len() > 1)
        .map(|(key, _)| key.clone())
        .collect();
    if duplicate_keys.is_empty() {
        return Ok(DedupResult {
            new_content: None,
            duplicates: Vec::new(),
        });
    }

    let mut duplicates = Vec::new();
    for key in &duplicate_keys {
        let values = &key_values[key];
        let is_sequence_field = SEQUENCE_FIELDS.contains(&key.as_str());

        if is_sequence_field {
            while mapping.remove(key.as_str()).is_some() {}
            let mut seq_builder = yaml_edit::YamlBuilder::sequence();
            for value in values {
                seq_builder = seq_builder.item(value);
            }
            let yaml_builder = seq_builder.build();
            let seq_file = yaml_builder.build();
            if let Some(seq_doc) = seq_file.documents().next() {
                if let Some(seq) = seq_doc.as_sequence() {
                    mapping.set(key.as_str(), seq);
                }
            }
        } else {
            let entries_to_remove: Vec<_> = mapping
                .entries()
                .enumerate()
                .filter(|(i, e)| *i > 0 && e.key_matches(key.as_str()))
                .map(|(_, e)| e)
                .collect();
            for entry in entries_to_remove {
                entry.remove();
            }
        }

        for _ in 0..(values.len() - 1) {
            duplicates.push(key.clone());
        }
    }

    Ok(DedupResult {
        new_content: Some(doc.to_string()),
        duplicates,
    })
}

/// If the top-level node is a sequence, return the rewritten content and
/// a count of the original list items (one issue per item).
fn unwrap_top_level_sequence(content: &str) -> Result<Option<(String, usize)>, FixerError> {
    let doc = match yaml_edit::Document::from_str(content) {
        Ok(d) => d,
        Err(_) => return Ok(None),
    };
    let Some(sequence) = doc.as_sequence() else {
        return Ok(None);
    };
    let items: Vec<_> = sequence.values().collect();

    if items.len() == 1 {
        let item_text = items[0].to_string().trim().to_string();
        return Ok(Some((item_text, 1)));
    }

    let all_single_key_mappings = items.iter().all(|item| {
        if let yaml_edit::YamlNode::Mapping(mapping_node) = item {
            mapping_node.entries().count() == 1
        } else {
            false
        }
    });
    if !all_single_key_mappings {
        return Ok(None);
    }

    let count = items.len();
    let new_mapping = yaml_edit::Mapping::new();
    let new_doc = yaml_edit::Document::from_mapping(new_mapping);
    let doc_mapping = new_doc.as_mapping().unwrap();
    for item in items {
        if let yaml_edit::YamlNode::Mapping(mapping_node) = item {
            for (key, value) in mapping_node.iter() {
                if let yaml_edit::YamlNode::Scalar(key_scalar) = key {
                    let key_str = key_scalar.as_string();
                    doc_mapping.set(key_str, value);
                }
            }
        }
    }
    Ok(Some((doc_mapping.to_string(), count)))
}

/// Outcome of dropping empty documents.
enum EmptyDocsOutcome {
    /// No empty documents found.
    NoChange,
    /// All documents were empty - file should be deleted.
    DeleteFile,
    /// Rewrite the file to keep only the first non-empty document.
    Rewrite(String),
}

fn drop_empty_documents(original: &str) -> Result<EmptyDocsOutcome, FixerError> {
    let yaml = yaml_edit::YamlFile::from_str(original)
        .map_err(|e| FixerError::Other(format!("Failed to parse YAML: {}", e)))?;
    let documents: Vec<yaml_edit::Document> = yaml.documents().collect();

    let mut has_empty = false;
    for doc in &documents {
        if let Some(mapping) = doc.as_mapping() {
            if mapping.entries().count() == 0 {
                has_empty = true;
                break;
            }
        } else if let Some(sequence) = doc.as_sequence() {
            if sequence.values().count() == 0 {
                has_empty = true;
                break;
            }
        } else if let Some(scalar) = doc.as_scalar() {
            let s = scalar.as_string();
            if s.trim().is_empty() || s.trim().starts_with("%YAML") {
                has_empty = true;
                break;
            }
        } else {
            has_empty = true;
            break;
        }
    }
    if !has_empty {
        return Ok(EmptyDocsOutcome::NoChange);
    }

    let non_empty_docs: Vec<yaml_edit::Document> = documents
        .into_iter()
        .filter(|doc: &yaml_edit::Document| {
            if let Some(mapping) = doc.as_mapping() {
                mapping.entries().count() > 0
            } else if let Some(sequence) = doc.as_sequence() {
                sequence.values().count() > 0
            } else if let Some(scalar) = doc.as_scalar() {
                let s = scalar.as_string();
                !s.trim().is_empty() && !s.trim().starts_with("%YAML")
            } else {
                false
            }
        })
        .collect();

    if non_empty_docs.is_empty() {
        return Ok(EmptyDocsOutcome::DeleteFile);
    }

    let leading_content = if let Some(pos) = original.find("---") {
        &original[..pos]
    } else {
        ""
    };
    let doc_content = non_empty_docs[0].to_string();
    let final_content = if !leading_content.trim().is_empty() {
        format!("{}{}", leading_content, doc_content)
    } else {
        doc_content
    };
    Ok(EmptyDocsOutcome::Rewrite(final_content))
}

/// Per-diagnostic action selector - the framework needs each diagnostic
/// to carry an action plan, but multiple diagnostics here describe the
/// same single rewrite. We therefore route them all to the same action,
/// which is just the file write.
pub fn detect(
    ws: &dyn Workspace,
    _preferences: &FixerPreferences,
) -> Result<Vec<Diagnostic>, FixerError> {
    let metadata_rel = PathBuf::from("debian/upstream/metadata");
    let bytes = match ws.read_file(&metadata_rel)? {
        Some(b) => b,
        None => return Ok(Vec::new()),
    };
    // Validate UTF-8 once; from there on we operate on the owned
    // `String` because the rewrite helpers below produce new strings
    // we need to take ownership of.
    let original_str = std::str::from_utf8(&bytes).map_err(|e| {
        FixerError::Other(format!(
            "debian/upstream/metadata is not valid UTF-8: {}",
            e
        ))
    })?;
    let original: String = original_str.to_string();
    let mut current: String = original.clone();
    let mut delete_file = false;

    let mut yaml_invalid_count = 0usize;
    let mut yaml_not_mapping_count = 0usize;
    let mut empty_docs_descs: Vec<(&'static str, &'static str)> = Vec::new();
    let mut dedup_fields: Vec<String> = Vec::new();

    // 1. Deduplicate keys.
    let dedup = dedup_keys(&current)?;
    if let Some(new_content) = dedup.new_content {
        current = new_content;
        yaml_invalid_count = 1;
        dedup_fields = dedup.duplicates;
    }

    // 2. Unwrap top-level sequence into a mapping.
    if let Some((new_content, count)) = unwrap_top_level_sequence(&current)? {
        current = new_content;
        yaml_not_mapping_count = count;
    }

    // 3. Drop empty documents.
    match drop_empty_documents(&current)? {
        EmptyDocsOutcome::NoChange => {}
        EmptyDocsOutcome::DeleteFile => {
            delete_file = true;
            empty_docs_descs.push((
                "debian/upstream/metadata is empty.",
                "Remove empty debian/upstream/metadata file.",
            ));
        }
        EmptyDocsOutcome::Rewrite(new_content) => {
            current = new_content;
            empty_docs_descs.push((
                "debian/upstream/metadata has extra empty YAML documents.",
                "Discard extra empty YAML documents in debian/upstream/metadata.",
            ));
        }
    }

    if !delete_file && current == original {
        return Ok(Vec::new());
    }

    // Build the single action this fixer applies.
    let action = if delete_file {
        Action::Filesystem(FilesystemAction::Delete {
            file: metadata_rel.clone(),
        })
    } else {
        Action::Filesystem(FilesystemAction::Write {
            file: metadata_rel.clone(),
            content: current.into_bytes(),
        })
    };

    // Build a diagnostic per surviving issue. They all carry the same
    // Write/Delete action; the applier deduplicates by value-equality,
    // so duplicates are no-ops, and the action survives as long as any
    // one diagnostic survives override filtering.
    let mut diagnostics: Vec<Diagnostic> = Vec::new();

    if yaml_invalid_count > 0 {
        let mut sorted_fields = dedup_fields;
        sorted_fields.sort();
        let desc = format!(
            "debian/upstream/metadata has duplicate values for fields {}.",
            sorted_fields.join(", ")
        );
        let label = format!(
            "Remove duplicate values for fields {} in debian/upstream/metadata.",
            sorted_fields.join(", ")
        );
        diagnostics.push(Diagnostic::with_actions(
            LintianIssue::source("upstream-metadata-yaml-invalid", Visibility::Warning),
            desc,
            label,
            vec![action.clone()],
        ));
    }
    for _ in 0..yaml_not_mapping_count {
        diagnostics.push(Diagnostic::with_actions(
            LintianIssue::source("upstream-metadata-not-yaml-mapping", Visibility::Warning),
            "debian/upstream/metadata is not a YAML mapping.",
            "Use YAML mapping in debian/upstream/metadata.",
            vec![action.clone()],
        ));
    }
    for (desc, label) in empty_docs_descs {
        diagnostics.push(Diagnostic::untagged(
            desc.to_string(),
            label.to_string(),
            vec![action.clone()],
        ));
    }

    if diagnostics.is_empty() {
        // The change isn't motivated by a specific lintian issue - emit
        // a generic untagged diagnostic so the action still runs.
        diagnostics.push(Diagnostic::untagged(
            "debian/upstream/metadata is invalid.".to_string(),
            "Fix invalid debian/upstream/metadata.".to_string(),
            vec![action],
        ));
    }

    Ok(diagnostics)
}

fn describe_aggregate(fixed: &[(Diagnostic, ActionPlan)], _actions: &[Action]) -> String {
    let mut seen = std::collections::HashSet::new();
    let mut parts: Vec<String> = Vec::new();
    for (_, plan) in fixed {
        if seen.insert(plan.label.clone()) {
            parts.push(plan.label.clone());
        }
    }
    parts.join(" ")
}

declare_detector! {
    name: "upstream-metadata-invalid",
    tags: [],
    triggers: [debian_workspace::Trigger::File("debian/upstream/metadata")],
    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 std::fs;
    use std::path::Path;
    use tempfile::TempDir;

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

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

    #[test]
    fn test_dedup_scalar_field() {
        let tmp = TempDir::new().unwrap();
        let upstream = tmp.path().join("debian/upstream");
        fs::create_dir_all(&upstream).unwrap();
        let path = upstream.join("metadata");
        fs::write(&path, "Name: foo\nName: bar\n").unwrap();

        run_apply(tmp.path()).unwrap();
        assert_eq!(fs::read_to_string(&path).unwrap(), "Name: foo\n");
    }

    #[test]
    fn test_unwrap_single_element_sequence() {
        let tmp = TempDir::new().unwrap();
        let upstream = tmp.path().join("debian/upstream");
        fs::create_dir_all(&upstream).unwrap();
        let path = upstream.join("metadata");
        fs::write(&path, "- Name: foo\n  Bug-Database: https://example.com\n").unwrap();

        run_apply(tmp.path()).unwrap();
        assert_eq!(
            fs::read_to_string(&path).unwrap(),
            "Name: foo\n  Bug-Database: https://example.com",
        );
    }
}