use crate::declare_detector;
use crate::diagnostic::{Action, ActionPlan, Diagnostic, FilesystemAction};
use crate::{Certainty, FixerError, FixerPreferences, LintianIssue, Visibility};
use debian_workspace::Workspace;
use std::path::PathBuf;
mod decopy {
use pyo3::prelude::*;
use pyo3::types::PyList;
use std::path::Path;
#[derive(Debug)]
pub enum Error {
Python(pyo3::PyErr),
NotAvailable,
}
impl From<pyo3::PyErr> for Error {
fn from(err: pyo3::PyErr) -> Self {
Error::Python(err)
}
}
impl std::fmt::Display for Error {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Error::Python(err) => write!(f, "Python error: {}", err),
Error::NotAvailable => write!(f, "decopy module not available"),
}
}
}
impl std::error::Error for Error {}
#[derive(Debug, Clone)]
pub struct FileGroup {
pub files: Vec<String>,
pub copyrights: Vec<String>,
pub license: String,
pub comments: Option<String>,
}
pub fn scan_tree(base_path: &Path) -> Result<(Vec<FileGroup>, Vec<String>), Error> {
tracing::debug!("Scanning tree with decopy at {}", base_path.display());
Python::attach(|py| {
if py.import("decopy").is_err() {
tracing::debug!("decopy module not available");
return Err(Error::NotAvailable);
}
let cmdoptions = py.import("decopy.cmdoptions")?;
let tree = py.import("decopy.fstree.tree")?;
let decopy_mod = py.import("decopy.decopy")?;
let copyright_reader = py.import("decopy.dep5doc.copyright_reader")?;
let grouping = py.import("decopy.grouping.group")?;
let abs_path = std::fs::canonicalize(base_path)
.map_err(|e| Error::Python(pyo3::exceptions::PyIOError::new_err(e.to_string())))?;
let abs_path_str = abs_path.to_str().unwrap();
let result = (|| -> Result<(Vec<FileGroup>, Vec<String>), Error> {
let root_arg = format!("--root={}", abs_path_str);
let output_arg = format!("--output={}/debian/copyright", abs_path_str);
tracing::debug!("Running decopy with root {}", abs_path_str);
let args = PyList::new(
py,
[
root_arg.as_str(),
"--no-progress",
"--mode=full",
output_arg.as_str(),
],
)?;
let options = cmdoptions.getattr("process_options")?.call1((args,))?;
let root_info = tree.getattr("RootInfo")?;
let filetree = root_info.call_method1("build", (&options,))?;
let copyright_ = copyright_reader
.getattr("read_copyright")?
.call1((&options,))?;
copyright_.call_method1("process", (&filetree,))?;
filetree.call_method1("process", (&options,))?;
copyright_.call_method1("remove_misplaced_files", (&options,))?;
let builtins = py.import("builtins")?;
let groups = decopy_mod
.call_method1("prepare_output_groups", (&filetree, ©right_, &options))?;
let mut file_groups = Vec::new();
let mut all_licenses = std::collections::HashSet::new();
let items_list: Vec<(Py<PyAny>, Py<PyAny>)> = builtins
.call_method1("list", (groups.call_method0("items")?,))?
.extract()?;
let mut sorted_items = items_list;
sorted_items.sort_by_cached_key(|(_, group)| {
group
.bind(py)
.call_method1("sort_key", (&options,))
.ok()
.map(|k| k.to_string())
.unwrap_or_default()
});
tracing::debug!("decopy returned {} candidate groups", sorted_items.len());
let tracker = grouping.getattr("TallyTracker")?.call0()?;
for (_key, group) in sorted_items {
let group = group.bind(py);
if !group.call_method0("copyright_block_valid")?.is_truthy()? {
tracing::debug!("Skipping group with invalid copyright block");
continue;
}
let group_licenses = group.getattr("licenses")?;
let keys: Vec<String> = builtins
.call_method1("list", (group_licenses.call_method0("keys")?,))?
.extract()?;
for key in keys {
all_licenses.insert(key);
}
let files = if options.getattr("glob")?.is_truthy()? {
group
.getattr("files")?
.call_method1("get_patterns", (&tracker,))?
} else {
group.getattr("files")?.call_method0("sorted_members")?
};
let files_list: Vec<String> =
builtins.call_method1("list", (files,))?.extract()?;
let copyrights = if group.getattr("copyrights")?.is_truthy()? {
let copyrights_obj = group.getattr("copyrights")?;
let sorted = copyrights_obj.call_method0("sorted_members")?;
let sorted_list = builtins.call_method1("list", (sorted,))?;
sorted_list.extract()?
} else {
vec!["Unknown".to_string()]
};
let license = group.getattr("license")?.extract::<String>()?;
let comment_str: String = group.call_method0("get_comments")?.extract()?;
let comments = if comment_str.is_empty() {
None
} else {
Some(comment_str)
};
file_groups.push(FileGroup {
files: files_list,
copyrights,
license,
comments,
});
}
let decopy_license = copyright_.getattr("registry")?;
let mut license_names = Vec::new();
let mut sorted_licenses: Vec<String> = all_licenses.into_iter().collect();
sorted_licenses.sort();
for license_key in sorted_licenses {
let license_ = decopy_license.call_method1("get", (license_key,))?;
let license_name = license_.getattr("name")?.extract::<String>()?;
license_names.push(license_name);
}
tracing::debug!(
"decopy produced {} file groups and {} licenses",
file_groups.len(),
license_names.len()
);
Ok((file_groups, license_names))
})();
result
})
}
}
pub fn detect(
ws: &dyn Workspace,
_preferences: &FixerPreferences,
) -> Result<Vec<Diagnostic>, FixerError> {
let copyright_rel = PathBuf::from("debian/copyright");
if ws.read_file(©right_rel)?.is_some() {
tracing::debug!("debian/copyright already exists; nothing to do");
return Ok(Vec::new());
}
let Some(base_path) = ws.base_path() else {
tracing::debug!("Workspace has no base path; cannot run decopy");
return Ok(Vec::new());
};
use debian_copyright::lossless::Copyright;
use debian_copyright::License;
let (file_groups, license_names) = match decopy::scan_tree(base_path) {
Ok(result) => result,
Err(decopy::Error::NotAvailable) => {
return Err(FixerError::MissingDependency("decopy".to_string()))
}
Err(decopy::Error::Python(err)) => {
return Err(FixerError::Other(format!("Error running decopy: {}", err)))
}
};
tracing::debug!(
"Building debian/copyright from {} file groups",
file_groups.len()
);
let mut copyright = Copyright::new();
for group in file_groups {
let files_refs: Vec<&str> = group.files.iter().map(|s| s.as_str()).collect();
let copyrights_refs: Vec<&str> = group.copyrights.iter().map(|s| s.as_str()).collect();
let license = License::Name(group.license.clone());
let mut files_para = copyright.add_files(&files_refs, ©rights_refs, &license);
if let Some(comment) = group.comments {
files_para.set_comment(&comment);
}
}
for license_name in license_names {
let license = License::Name(license_name);
let mut license_para = copyright.add_license(&license);
license_para.set_comment("Add the corresponding license text here");
}
let issue = LintianIssue::source_with_info("no-copyright-file", Visibility::Error, vec![]);
Ok(vec![Diagnostic::with_plans(
issue,
"debian/copyright file is missing.",
vec![ActionPlan {
label: "Create a debian/copyright file.".to_string(),
opinionated: false,
certainty: Some(Certainty::Possible),
actions: vec![Action::Filesystem(FilesystemAction::Write {
file: copyright_rel,
content: copyright.to_string().into_bytes(),
})],
}],
)
.with_certainty(Certainty::Certain)])
}
declare_detector! {
name: "no-copyright-file",
tags: ["no-copyright-file"],
triggers: [
debian_workspace::Trigger::File("debian/copyright"),
],
detect: |ws, prefs| detect(ws, prefs),
}
#[cfg(test)]
mod tests {
#[test]
fn test_debian_copyright_field_order() {
use debian_copyright::lossless::Copyright;
use debian_copyright::License;
let mut copyright = Copyright::new();
let files = vec!["*"];
let copyrights = vec!["Unknown"];
let license = License::Name("GPL-2+".to_string());
let mut para = copyright.add_files(&files, ©rights, &license);
para.set_comment("Test comment");
let output = copyright.to_string();
println!("Generated copyright file:\n{}", output);
let lines: Vec<&str> = output.lines().collect();
let mut files_idx = None;
let mut copyright_idx = None;
let mut license_idx = None;
let mut comment_idx = None;
for (idx, line) in lines.iter().enumerate() {
if line.starts_with("Files:") {
files_idx = Some(idx);
} else if line.starts_with("Copyright:") {
copyright_idx = Some(idx);
} else if line.starts_with("License:") && !line.contains("format") {
license_idx = Some(idx);
} else if line.starts_with("Comment:") {
comment_idx = Some(idx);
}
}
println!(
"Field positions - Files: {:?}, Copyright: {:?}, License: {:?}, Comment: {:?}",
files_idx, copyright_idx, license_idx, comment_idx
);
assert!(files_idx.is_some(), "Files field not found");
assert!(copyright_idx.is_some(), "Copyright field not found");
assert!(license_idx.is_some(), "License field not found");
let f = files_idx.unwrap();
let c = copyright_idx.unwrap();
let l = license_idx.unwrap();
assert!(f < c, "Files ({}) should come before Copyright ({})", f, c);
assert!(
c < l,
"Copyright ({}) should come before License ({})",
c,
l
);
if let Some(com) = comment_idx {
assert!(
l < com,
"License ({}) should come before Comment ({})",
l,
com
);
}
}
}