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"];
struct DedupResult {
new_content: Option<String>,
duplicates: Vec<String>,
}
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,
})
}
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)))
}
enum EmptyDocsOutcome {
NoChange,
DeleteFile,
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))
}
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()),
};
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();
let dedup = dedup_keys(¤t)?;
if let Some(new_content) = dedup.new_content {
current = new_content;
yaml_invalid_count = 1;
dedup_fields = dedup.duplicates;
}
if let Some((new_content, count)) = unwrap_top_level_sequence(¤t)? {
current = new_content;
yaml_not_mapping_count = count;
}
match drop_empty_documents(¤t)? {
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());
}
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(),
})
};
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() {
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",
);
}
}