use std::path::{Path, PathBuf};
use debian_changelog::ChangeLog;
use debian_control::lossless::Control;
use debian_copyright::lossless::Copyright;
use debian_watch::parse::ParsedWatchFile;
use dep3::lossless::PatchHeader;
use makefile_lossless::Makefile;
use patchkit::edit::Patch;
use patchkit::quilt::Series;
use toml_edit::DocumentMut;
use crate::{Error, Version};
pub trait Editor<T>: std::ops::Deref<Target = T> + std::ops::DerefMut<Target = T> {
fn commit(self: Box<Self>) -> Result<(), Error>;
}
pub trait Workspace {
fn package(&self) -> Option<&str>;
fn current_version(&self) -> Option<&Version>;
fn parsed_control(&self) -> Result<Control, Error>;
fn parsed_changelog(&self) -> Result<ChangeLog, Error>;
fn parsed_copyright(&self) -> Result<Copyright, Error>;
fn parsed_upstream_metadata(&self) -> Result<yaml_edit::YamlFile, Error>;
fn parsed_watch(&self) -> Result<ParsedWatchFile, Error>;
fn parsed_rules(&self) -> Result<Makefile, Error>;
fn parsed_patches_series(&self) -> Result<Option<Series>, Error> {
let rel = Path::new("debian/patches/series");
match self.read_file(rel)? {
None => Ok(None),
Some(bytes) => {
let series = Series::read(&bytes[..]).map_err(Error::Io)?;
Ok(Some(series))
}
}
}
fn parsed_patch(&self, rel: &Path) -> Result<Option<(Option<PatchHeader>, Patch)>, Error> {
let Some(bytes) = self.read_file(rel)? else {
return Ok(None);
};
let content = std::str::from_utf8(&bytes)
.map_err(|e| Error::Parse(format!("{} is not valid UTF-8: {}", rel.display(), e)))?;
let header_end = dep3::lossless::header_end(content);
let header_text = &content[..header_end];
let header = if header_text.trim().is_empty() {
None
} else {
header_text.parse::<PatchHeader>().ok()
};
let patch = patchkit::edit::parse(&content[header_end..]).tree();
Ok(Some((header, patch)))
}
fn source_format(&self) -> Result<Option<String>, Error>;
fn control(&self) -> Result<Box<dyn Editor<Control> + '_>, Error>;
fn changelog(&self) -> Result<Box<dyn Editor<ChangeLog> + '_>, Error>;
fn parsed_debcargo(&self) -> Result<Option<DocumentMut>, Error> {
let rel = Path::new("debian/debcargo.toml");
match self.read_file(rel)? {
None => Ok(None),
Some(bytes) => {
let text = String::from_utf8(bytes.into_owned()).map_err(|e| {
Error::Parse(format!("debcargo.toml is not valid UTF-8: {}", e))
})?;
let doc: DocumentMut = text
.parse()
.map_err(|e| Error::Parse(format!("Failed to parse debcargo.toml: {}", e)))?;
Ok(Some(doc))
}
}
}
fn debcargo(&self) -> Result<Option<Box<dyn Editor<DocumentMut> + '_>>, Error>;
fn read_file(&self, rel: &Path) -> Result<Option<std::borrow::Cow<'_, [u8]>>, Error>;
fn write_file(&self, rel: &Path, content: &[u8]) -> Result<(), Error>;
fn list_dir(&self, rel: &Path) -> Result<Option<Vec<String>>, Error>;
fn walk_dir(&self, rel: &Path) -> Result<Option<Vec<PathBuf>>, Error> {
let Some(top_entries) = self.list_dir(rel)? else {
return Ok(None);
};
let mut out = Vec::new();
let mut stack: Vec<(PathBuf, Vec<String>)> = vec![(rel.to_path_buf(), top_entries)];
while let Some((dir, entries)) = stack.pop() {
for name in entries {
let child = dir.join(&name);
match self.list_dir(&child)? {
Some(sub) => stack.push((child, sub)),
None => out.push(child),
}
}
}
Ok(Some(out))
}
fn file_mode(&self, rel: &Path) -> Result<Option<u32>, Error>;
fn base_path(&self) -> Option<&Path> {
None
}
}
pub fn compat_level(ws: &dyn Workspace) -> Result<Option<u8>, Error> {
if let Some(bytes) = ws.read_file(Path::new("debian/compat"))? {
if let Ok(text) = std::str::from_utf8(&bytes) {
let trimmed = text
.split_once('#')
.map_or(text, |(before, _)| before)
.trim();
if let Ok(level) = trimmed.parse::<u8>() {
return Ok(Some(level));
}
}
}
let control = match ws.parsed_control() {
Ok(c) => c,
Err(Error::NotFound) => return Ok(None),
Err(e) => return Err(e),
};
let Some(source) = control.source() else {
return Ok(None);
};
if let Some(dh_compat) = source.as_deb822().get("X-DH-Compat") {
let trimmed = dh_compat
.split_once('#')
.map_or(dh_compat.as_str(), |(before, _)| before)
.trim();
if let Ok(level) = trimmed.parse::<u8>() {
return Ok(Some(level));
}
}
let Some(build_depends) = source.build_depends() else {
return Ok(None);
};
let Some(rel) = build_depends
.entries()
.flat_map(|entry| entry.relations().collect::<Vec<_>>())
.find(|r| r.try_name().as_deref() == Some("debhelper-compat"))
else {
return Ok(None);
};
Ok(rel
.version()
.and_then(|(_op, v)| v.to_string().parse::<u8>().ok()))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::fs_workspace::FsWorkspace;
use std::collections::BTreeMap;
use tempfile::TempDir;
#[derive(Default)]
struct MockWorkspace {
files: BTreeMap<PathBuf, Vec<u8>>,
}
impl MockWorkspace {
fn with_file(mut self, rel: &str, content: &[u8]) -> Self {
self.files.insert(PathBuf::from(rel), content.to_vec());
self
}
fn dirs(&self) -> std::collections::BTreeSet<PathBuf> {
let mut dirs = std::collections::BTreeSet::new();
for f in self.files.keys() {
let mut cur = f.parent();
while let Some(p) = cur {
if p.as_os_str().is_empty() {
break;
}
dirs.insert(p.to_path_buf());
cur = p.parent();
}
}
dirs
}
}
impl Workspace for MockWorkspace {
fn package(&self) -> Option<&str> {
None
}
fn current_version(&self) -> Option<&Version> {
None
}
fn parsed_control(&self) -> Result<Control, Error> {
Err(Error::NotFound)
}
fn parsed_changelog(&self) -> Result<ChangeLog, Error> {
Err(Error::NotFound)
}
fn parsed_copyright(&self) -> Result<Copyright, Error> {
Err(Error::NotFound)
}
fn parsed_upstream_metadata(&self) -> Result<yaml_edit::YamlFile, Error> {
Err(Error::NotFound)
}
fn parsed_watch(&self) -> Result<ParsedWatchFile, Error> {
Err(Error::NotFound)
}
fn parsed_rules(&self) -> Result<Makefile, Error> {
Err(Error::NotFound)
}
fn source_format(&self) -> Result<Option<String>, Error> {
Ok(None)
}
fn control(&self) -> Result<Box<dyn Editor<Control> + '_>, Error> {
Err(Error::NotFound)
}
fn changelog(&self) -> Result<Box<dyn Editor<ChangeLog> + '_>, Error> {
Err(Error::NotFound)
}
fn debcargo(&self) -> Result<Option<Box<dyn Editor<DocumentMut> + '_>>, Error> {
Ok(None)
}
fn read_file(&self, rel: &Path) -> Result<Option<std::borrow::Cow<'_, [u8]>>, Error> {
Ok(self
.files
.get(rel)
.map(|v| std::borrow::Cow::Borrowed(v.as_slice())))
}
fn write_file(&self, _rel: &Path, _content: &[u8]) -> Result<(), Error> {
unimplemented!("not needed by tests")
}
fn list_dir(&self, rel: &Path) -> Result<Option<Vec<String>>, Error> {
let dirs = self.dirs();
if !dirs.contains(rel) {
return Ok(None);
}
let mut names = std::collections::BTreeSet::new();
for f in self.files.keys() {
if f.parent() == Some(rel) {
names.insert(f.file_name().unwrap().to_string_lossy().into_owned());
}
}
for d in &dirs {
if d.parent() == Some(rel) {
names.insert(d.file_name().unwrap().to_string_lossy().into_owned());
}
}
Ok(Some(names.into_iter().collect()))
}
fn file_mode(&self, _rel: &Path) -> Result<Option<u32>, Error> {
Ok(None)
}
}
fn make_pkg_with_control(dir: &Path, control: &str) {
let debian = dir.join("debian");
std::fs::create_dir_all(&debian).unwrap();
std::fs::write(debian.join("control"), control).unwrap();
}
fn fs_workspace(dir: &Path) -> FsWorkspace {
FsWorkspace::new(dir, None, None)
}
#[test]
fn default_walk_dir_recurses_nested_dirs() {
let ws = MockWorkspace::default()
.with_file("src/top.txt", b"a")
.with_file("src/sub/deep.txt", b"b")
.with_file("src/sub/other.txt", b"c");
let mut files = ws.walk_dir(Path::new("src")).unwrap().unwrap();
files.sort();
assert_eq!(
files,
vec![
PathBuf::from("src/sub/deep.txt"),
PathBuf::from("src/sub/other.txt"),
PathBuf::from("src/top.txt"),
]
);
}
#[test]
fn default_walk_dir_missing_returns_none() {
let ws = MockWorkspace::default().with_file("src/top.txt", b"a");
assert_eq!(ws.walk_dir(Path::new("nonexistent")).unwrap(), None);
}
#[test]
fn default_walk_dir_empty_dir_returns_empty_vec() {
let ws = EmptyDirWorkspace;
assert_eq!(
ws.walk_dir(Path::new("empty")).unwrap(),
Some(Vec::<PathBuf>::new())
);
}
struct EmptyDirWorkspace;
impl Workspace for EmptyDirWorkspace {
fn package(&self) -> Option<&str> {
None
}
fn current_version(&self) -> Option<&Version> {
None
}
fn parsed_control(&self) -> Result<Control, Error> {
Err(Error::NotFound)
}
fn parsed_changelog(&self) -> Result<ChangeLog, Error> {
Err(Error::NotFound)
}
fn parsed_copyright(&self) -> Result<Copyright, Error> {
Err(Error::NotFound)
}
fn parsed_upstream_metadata(&self) -> Result<yaml_edit::YamlFile, Error> {
Err(Error::NotFound)
}
fn parsed_watch(&self) -> Result<ParsedWatchFile, Error> {
Err(Error::NotFound)
}
fn parsed_rules(&self) -> Result<Makefile, Error> {
Err(Error::NotFound)
}
fn source_format(&self) -> Result<Option<String>, Error> {
Ok(None)
}
fn control(&self) -> Result<Box<dyn Editor<Control> + '_>, Error> {
Err(Error::NotFound)
}
fn changelog(&self) -> Result<Box<dyn Editor<ChangeLog> + '_>, Error> {
Err(Error::NotFound)
}
fn debcargo(&self) -> Result<Option<Box<dyn Editor<DocumentMut> + '_>>, Error> {
Ok(None)
}
fn read_file(&self, _rel: &Path) -> Result<Option<std::borrow::Cow<'_, [u8]>>, Error> {
Ok(None)
}
fn write_file(&self, _rel: &Path, _content: &[u8]) -> Result<(), Error> {
unimplemented!()
}
fn list_dir(&self, rel: &Path) -> Result<Option<Vec<String>>, Error> {
if rel == Path::new("empty") {
Ok(Some(vec![]))
} else {
Ok(None)
}
}
fn file_mode(&self, _rel: &Path) -> Result<Option<u32>, Error> {
Ok(None)
}
}
#[test]
fn compat_level_from_compat_file() {
let tmp = TempDir::new().unwrap();
std::fs::create_dir_all(tmp.path().join("debian")).unwrap();
std::fs::write(tmp.path().join("debian/compat"), "10\n").unwrap();
assert_eq!(compat_level(&fs_workspace(tmp.path())).unwrap(), Some(10));
}
#[test]
fn compat_level_from_build_depends() {
let tmp = TempDir::new().unwrap();
make_pkg_with_control(
tmp.path(),
"Source: foo\nBuild-Depends: debhelper-compat (= 13)\n\nPackage: foo\nArchitecture: any\nDescription: x\n y\n",
);
assert_eq!(compat_level(&fs_workspace(tmp.path())).unwrap(), Some(13));
}
#[test]
fn compat_level_build_depends_without_debhelper_compat() {
let tmp = TempDir::new().unwrap();
make_pkg_with_control(
tmp.path(),
"Source: foo\nBuild-Depends: debhelper (>= 12)\n\nPackage: foo\nArchitecture: any\nDescription: x\n y\n",
);
assert_eq!(compat_level(&fs_workspace(tmp.path())).unwrap(), None);
}
#[test]
fn compat_level_none_when_no_sources() {
let tmp = TempDir::new().unwrap();
make_pkg_with_control(
tmp.path(),
"Source: foo\n\nPackage: foo\nArchitecture: any\nDescription: x\n y\n",
);
assert_eq!(compat_level(&fs_workspace(tmp.path())).unwrap(), None);
}
}